diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 27c8378..e5f4653 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -5,10 +5,7 @@ declare(strict_types=1); namespace App\BusProNet\DataProcessor; use App\BusProNet\Constants; -use App\BusProNet\Model\Address; use App\BusProNet\Model\Booking; -use App\BusProNet\Model\Communication; -use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BankAccountDto; @@ -21,17 +18,19 @@ use App\Service\InsuranceService; /** * Processes booking form data and converts it into BusProNet API payload format. * - * This processor handles the complex transformation of booking edit form data into the structured - * array format required by the BusProNet API. It manages service mappings between participants - * and various booking components like additional services, transportation, and accommodations. - * The processor ensures data consistency by resetting and rebuilding participant-to-service - * mappings based on form selections, while maintaining proper API payload structure. + * 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 InsuranceService $insuranceService, private readonly BookingPriceCalculatorService $priceCalculatorService, + private readonly ServiceMappingCollector $mappingCollector, + private readonly ParticipantServiceProcessor $serviceProcessor, + private readonly BookingPayloadBuilder $payloadBuilder, + private readonly PersonalDataSynchronizer $personalDataSynchronizer, ) { } @@ -66,7 +65,6 @@ class BookingDataProcessor } foreach ($booking->participants as $index => $participant) { - /** @var PersonalData $participant */ $participantData = ParticipantDto::fromPersonalData($participant); $participantData->index = $index; @@ -230,17 +228,6 @@ class BookingDataProcessor * updates, and generates the complete payload structure including booking metadata, * participant information, services, transportation, and accommodation details. * - * The process involves: - * - Resetting existing service-to-participant mappings - * - Rebuilding mappings based on current form selections - * - Adding new services from travel data when participants select them - * - Removing services with no participant mappings - * - Updating participant personal data from form input - * - Building the final API payload structure - * - * IMPORTANT: The applicant's address must be preserved from the original booking data stored in the DTO, - * as it can be lost during form binding when ParticipantDto stores address references. - * * @param BookingDto|null $formData The booking edit form data containing updated participant and service selections * * @return array The structured payload array ready for BusProNet API submission @@ -259,23 +246,23 @@ class BookingDataProcessor // 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->resetServiceMappings($bookingData); + $this->serviceProcessor->resetServiceMappings($bookingData); foreach ($formData->participants as $participant) { - $this->processParticipantServices($participant, $bookingData, $travelData); + $this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData); } - $this->removeUnusedServices($bookingData); - $this->updateParticipantPersonalData($formData->participants, $bookingData); + $this->serviceProcessor->removeUnusedServices($bookingData); + $this->personalDataSynchronizer->updateParticipantPersonalData($formData->participants, $bookingData); - $payload = $this->buildBasePayload($bookingData); - $this->addBankAccountToPayload($payload, $bookingData); - $this->buildParticipantPayload($payload, $bookingData, $formData->participants); - $this->buildServicesPayload($payload, $bookingData); - $this->buildPickupPayload($payload, $bookingData); + $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); // Add purchase vouchers if any exist - $purchaseVouchers = $this->collectPurchaseVouchers($formData); + $purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData); if (false === empty($purchaseVouchers)) { $payload['gutscheine']['gutschein'] = []; foreach ($purchaseVouchers as $code) { @@ -288,499 +275,6 @@ class BookingDataProcessor return $payload; } - /** - * Resets all existing participant-to-service mappings to start with a clean slate. - * - * This ensures that service assignments are rebuilt from scratch based on current form selections. - * Includes resetting room mappings to allow room reassignments during edit. - * - * @param Booking $bookingData The booking data object containing services to reset - */ - private function resetServiceMappings(Booking $bookingData): void - { - $servicesToReset = [ - ...$bookingData->additionalServices, - ...$bookingData->transportationServices, - ...$bookingData->pickupsOutbound, - ...$bookingData->pickupsInbound, - ...$bookingData->rooms, - ...$bookingData->insurances, - ]; - - foreach ($servicesToReset as $service) { - $service->mapping = []; - } - } - - /** - * Processes all services for a single participant. - * - * This orchestrator method handles the complete service assignment workflow for one participant, - * including additional services, transportation services, pickup locations, and room assignments. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - * @param Travel $travelData The travel data containing available services - */ - private function processParticipantServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void - { - if (true === $participant->isCanceled()) { - return; - } - - $this->processAdditionalServices($participant, $bookingData, $travelData); - $this->processTransportationServices($participant, $bookingData, $travelData); - $this->processPickupLocations($participant, $bookingData); - $this->processRoomAssignment($participant, $bookingData); - $this->processInsurance($participant, $bookingData, $travelData); - } - - /** - * Collects additional services from a participant for mapping. - * - * Extracts all additional services (courses, board, rentals, ski pass, rental insurance, - * additional services) from a participant into a flat array of Service objects. - * - * @param ParticipantDto $participant The participant data - * - * @return array Array of services selected by this participant - */ - private function collectParticipantAdditionalServices(ParticipantDto $participant): array - { - $services = [ - ...$participant->courses, - ...$participant->additionalServices, - ...$participant->board, - ...$participant->rentals, - ]; - - // Add ski pass if selected (single service, not an array) - if (null !== $participant->skiPass) { - $services[] = $participant->skiPass; - } - - // Add rental insurance if selected (single service, not an array) - if (null !== $participant->rentalInsurance) { - $services[] = $participant->rentalInsurance; - } - - return $services; - } - - /** - * Processes additional services for a participant. - * - * Maps additional services (courses, ski passes, board options, rentals) to the participant. - * Adds new services to the booking if they don't already exist and sets individual pricing. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - * @param Travel $travelData The travel data containing available services - */ - private function processAdditionalServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void - { - $servicesToMap = $this->collectParticipantAdditionalServices($participant); - - foreach ($servicesToMap as $service) { - if (false === isset($bookingData->additionalServices[$service->id])) { - $serviceToAdd = $travelData->additionalServices[$service->id] ?? null; - if (null !== $serviceToAdd) { - $bookingData->additionalServices[$service->id] = $serviceToAdd; - $bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; - } - } - $bookingData->additionalServices[$service->id]->mapping[] = $participant->index; - } - } - - /** - * Processes transportation services for a participant. - * - * Maps transportation services (both directions: to and from destination) to the participant. - * Adds new transportation services to the booking if they don't already exist. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - * @param Travel $travelData The travel data containing available services - */ - private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void - { - foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) { - if (false === isset($bookingData->transportationServices[$service->id])) { - $serviceToAdd = $travelData->transportationServices[$service->id] ?? null; - if (null !== $serviceToAdd) { - $bookingData->transportationServices[$service->id] = $serviceToAdd; - $bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; - } - } - $bookingData->transportationServices[$service->id]->mapping[] = $participant->index; - } - } - - /** - * Processes pickup locations for participants using bus transportation. - * - * Only processes pickup locations for bus transportation services and maps the participant - * to their selected pickup location. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - */ - private function processPickupLocations(ParticipantDto $participant, Booking $bookingData): void - { - // Check if either transportation direction is BUS and pickup is selected - $hasOutboundBus = null !== $participant->transportationOutbound && 'BUS' === $participant->transportationOutbound->subType; - $hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType; - - if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) { - if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) { - $bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup; - } - $bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index; - } - } - - /** - * Processes room assignment for a participant. - * - * Maps the participant to their assigned room. Allows room reassignment during - * booking edits while maintaining the constraint that participants can only be - * assigned to room types that have already been booked. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - */ - private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void - { - if (null === $participant->assignedRoomId) { - return; - } - - // Find the room in the existing booking by ID - foreach ($bookingData->rooms as $room) { - if ($room->id === $participant->assignedRoomId) { - $room->mapping[] = $participant->index; - break; - } - } - } - - /** - * Processes travel insurance for a participant. - * - * Maps insurance to the participant. Adds new insurances to the booking if they don't exist. - * Insurance can be either individual or package-based, with automatic price-tier adjustment. - * - * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. - * - * @param ParticipantDto $participant The participant data from the form - * @param Booking $bookingData The booking data object to update - * @param Travel $travelData The travel data containing available insurances - */ - private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void - { - // Skip if no insurance or if participant selected "keine Versicherung gewünscht" - if (null === $participant->insurance || $participant->insurance->isNoInsurance()) { - return; - } - - $insurance = $participant->insurance; - - if (false === isset($bookingData->insurances[$insurance->id])) { - $insuranceToAdd = $travelData->insurances[$insurance->id] ?? null; - if (null !== $insuranceToAdd) { - $bookingData->insurances[$insurance->id] = $insuranceToAdd; - $bookingData->insurances[$insurance->id]->individualPrice[$participant->index] = $insuranceToAdd->price; - } - } - - // Only add mapping if insurance exists in booking data - // This can legitimately be false if the insurance is no longer available in current travel data - if (isset($bookingData->insurances[$insurance->id])) { - $bookingData->insurances[$insurance->id]->mapping[] = $participant->index; - } - } - - /** - * Removes services and pickups with no participant mappings. - * - * Cleans up unused services to prevent empty services from being sent to the API. - * This includes additional services, transportation services, pickup locations, and insurances. - * - * @param Booking $bookingData The booking data object to clean up - */ - private function removeUnusedServices(Booking $bookingData): void - { - foreach ($bookingData->additionalServices as $service) { - if (0 === count($service->mapping)) { - unset($bookingData->additionalServices[$service->id]); - } - } - - foreach ($bookingData->transportationServices as $service) { - if (0 === count($service->mapping)) { - unset($bookingData->transportationServices[$service->id]); - } - } - - foreach ($bookingData->pickupsOutbound as $pickup) { - if (0 === count($pickup->mapping)) { - unset($bookingData->pickupsOutbound[$pickup->id]); - } - } - - foreach ($bookingData->pickupsInbound as $pickup) { - if (0 === count($pickup->mapping)) { - unset($bookingData->pickupsInbound[$pickup->id]); - } - } - - foreach ($bookingData->insurances as $insurance) { - if (0 === count($insurance->mapping)) { - unset($bookingData->insurances[$insurance->id]); - } - } - } - - /** - * Updates participant personal data from form input. - * - * Only processes participants with status 'F' (active/confirmed participants). - * Updates all personal data fields and communication information. - * - * IMPORTANT: The applicant's address must never be modified. This method updates - * participant addresses independently to ensure applicant data remains intact. - * - * @param array $participants The participants array from the form - * @param Booking $bookingData The booking data object to update - */ - private function updateParticipantPersonalData(array $participants, Booking $bookingData): void - { - foreach ($participants as $participant) { - if ('F' !== $participant->status) { - continue; - } - - $bookingData->participants[$participant->index]->firstName = $participant->firstName; - $bookingData->participants[$participant->index]->name = $participant->lastName; - $bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth; - $bookingData->participants[$participant->index]->gender = $participant->gender; - $bookingData->participants[$participant->index]->nationality = $participant->nationality; - $bookingData->participants[$participant->index]->height = $participant->height; - $bookingData->participants[$participant->index]->weight = $participant->weight; - $bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize; - - // Update address if provided - // Create new Address instance to avoid modifying any shared object references - if (null !== $participant->address) { - $newAddress = new Address(); - $newAddress->street = $participant->address->street; - $newAddress->postCode = $participant->address->postCode; - $newAddress->city = $participant->address->city; - $newAddress->district = $participant->address->district; - $newAddress->country = $participant->address->country; - - $bookingData->participants[$participant->index]->address = $newAddress; - } - - if ($participant->email || $participant->mobile) { - if (null === $bookingData->participants[$participant->index]->communication) { - $bookingData->participants[$participant->index]->communication = new Communication(); - } - $bookingData->participants[$participant->index]->communication->email = $participant->email; - $bookingData->participants[$participant->index]->communication->mobile = $participant->mobile; - } - } - } - - /** - * Builds the base API payload structure with booking information. - * - * Creates the main structure that will be populated with detailed data sections. - * - * @param Booking $bookingData The booking data object - * - * @return array The base payload structure - */ - private function buildBasePayload(Booking $bookingData): array - { - return [ - 'idbuchung' => $bookingData->id, - 'status' => $bookingData->status, - 'idagentur' => $bookingData->agencyId, - 'idreise' => $bookingData->dateId, - 'idpartner' => $bookingData->hotelId, - 'anmelder' => $bookingData->applicant->toPayload(), - 'zahlung' => [ - '@idzahlungsart' => $bookingData->paymentId, - '@bezeichnung' => $bookingData->paymentLabel, - '@art' => $bookingData->paymentType, - ], - 'teilnehmerliste' => [ - 'teilnehmer' => [], - ], - 'zusatzleistungen' => [ - 'zusatzleistung' => [], - ], - 'beförderungen' => [ - 'beförderung' => [], - ], - 'ferienzielunterbringungen' => [ - 'ferienzielunterbringung' => [], - ], - ]; - } - - /** - * Adds bank account information to the payload if present. - * - * Bank account information is required for direct debit payments. - * - * @param array $payload The payload array to modify - * @param Booking $bookingData The booking data object - */ - private function addBankAccountToPayload(array &$payload, Booking $bookingData): void - { - if (null !== $bookingData->bankAccount) { - $payload['zahlung']['bankverbindung'] = [ - '@kreditinstitut' => $bookingData->bankAccount->bankName, - '@iban' => $bookingData->bankAccount->iban, - '@bic' => $bookingData->bankAccount->bic, - '@kontoinhaber' => $bookingData->bankAccount->holder, - ]; - } - } - - /** - * Builds the participant list section of the payload. - * - * Includes status, personal data, and wishes (room remarks, license plate) for each participant. - * - * @param array $payload The payload array to modify - * @param Booking $bookingData The booking data object - * @param array $participantDtos The participant DTOs from the form (for wishes data) - */ - private function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void - { - foreach ($bookingData->participants as $index => $participant) { - $participantPayload = [ - '@id' => $index + 1, - 'status' => $bookingData->participantsStatus[$index], - ...$participant->toPayload(), - ]; - - // Add wishes (room remarks, license plate) from form DTO - if (isset($participantDtos[$index])) { - $dto = $participantDtos[$index]; - if (null !== $dto->remarksRoom || null !== $dto->licensePlate) { - $wishes = []; - - if (null !== $dto->remarksRoom && '' !== trim($dto->remarksRoom)) { - $wishes['unterbringungswunsch'] = $dto->remarksRoom; - } - - if (null !== $dto->licensePlate && '' !== trim($dto->licensePlate)) { - $wishes['beförderungswunsch'] = $dto->licensePlate; - } - - if (false === empty($wishes)) { - $participantPayload['wünsche'] = $wishes; - } - } - - // Add promotional voucher as promotionalCode - $promotionalCode = $this->getPromoVoucherCodeForParticipant($dto); - if (null !== $promotionalCode) { - $participantPayload['aktionscode'] = $promotionalCode; - } - - // Add goodwill voucher as participant-level einloesecode - $goodwillCode = $this->getGoodwillVoucherCodeForParticipant($dto); - if (null !== $goodwillCode) { - $participantPayload['einloesecode'] = $goodwillCode; - } - } - - $payload['teilnehmerliste']['teilnehmer'][] = $participantPayload; - } - } - - /** - * Builds the services sections of the payload. - * - * Includes additional services, transportation services, and accommodation details - * with participant mappings and quantities. - * - * @param array $payload The payload array to modify - * @param Booking $bookingData The booking data object - */ - private function buildServicesPayload(array &$payload, Booking $bookingData): void - { - foreach ($bookingData->additionalServices as $service) { - $payload['zusatzleistungen']['zusatzleistung'][] = [ - '@idleistung' => $service->id, - '@anzahl' => count($service->mapping), - '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), - ]; - } - - foreach ($bookingData->transportationServices as $service) { - $payload['beförderungen']['beförderung'][] = [ - '@idleistung' => $service->id, - '@anzahl' => count($service->mapping), - '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), - ]; - } - - foreach ($bookingData->rooms as $room) { - $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ - '@idzimmer' => $room->id, - '@kategorie' => $room->category, - '@idverpflegung' => $room->boardId, - '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, - '@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null, - '@anzahl' => $room->totalCount, - '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)), - ]; - } - - // Add insurances with participant mappings - if (false === empty($bookingData->insurances)) { - $payload['versicherungen']['versicherung'] = []; - foreach ($bookingData->insurances as $insurance) { - if (count($insurance->mapping) > 0) { - $payload['versicherungen']['versicherung'][] = [ - '@idversicherung' => $insurance->id, - '@anzahl' => count($insurance->mapping), - '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)), - ]; - } - } - } - } - - /** - * Builds the pickup locations section of the payload. - * - * Only included in payload if there are actual pickup assignments for bus transportation. - * - * @param array $payload The payload array to modify - * @param Booking $bookingData The booking data object - */ - private function buildPickupPayload(array &$payload, Booking $bookingData): void - { - if (0 < count($bookingData->pickupsOutbound)) { - $payload['zustiege']['zustieg'] = []; - foreach ($bookingData->pickupsOutbound as $pickup) { - $payload['zustiege']['zustieg'][] = [ - '@idzustieg' => $pickup->id, - '@anzahl' => count($pickup->mapping), - '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)), - ]; - } - } - } - /** * Creates a booking request payload for new bookings (inquiry or final booking). * @@ -799,455 +293,7 @@ class BookingDataProcessor // Apply bulk insurance if enabled (modifies DTO in place) $this->applyBulkInsuranceIfActive($bookingDto); - $firstParticipant = $bookingDto->participants[0]; - - $payload = [ - 'buchungsart' => $bookingType, - 'status' => $bookingDto->bookingStatus, - '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 ?? '', - ]; - - // DO NOT include personId or addressId in create mode - // BPN will automatically match existing customers by exact personal data (name, DOB, address) - // Including IDs would prevent automatic matching and could cause data inconsistencies - - 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; - } - } - - // Determine if this is an inquiry booking for voucher handling - $isInquiryBooking = 'A' === $bookingDto->bookingStatus; - - // 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 ?? '', - ]; - - // DO NOT include personId or addressId in create mode - // BPN will automatically match existing customers by exact personal data (name, DOB, address) - // Including IDs would prevent automatic matching and could cause data inconsistencies - - 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; - } - } - - // Add promotional voucher as aktionscode (excluded from inquiry bookings) - if (false === $isInquiryBooking) { - $promotionalCode = $this->getPromoVoucherCodeForParticipant($participant); - if (null !== $promotionalCode) { - $participantData['aktionscode'] = $promotionalCode; - } - } - - // Add goodwill voucher as participant-level einloesecode - // Goodwill vouchers are excluded from inquiry bookings (status='A') but included in final bookings (status='F') - // They can be sent in both validation requests (buchungsart=Anfrage) and commit requests (buchungsart=Buchung) - if (false === $isInquiryBooking) { - $goodwillCode = $this->getGoodwillVoucherCodeForParticipant($participant); - if (null !== $goodwillCode) { - $participantData['einloesecode'] = $goodwillCode; - } - } - - $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 purchase vouchers (regular purchase vouchers, excluding goodwill vouchers) - // All vouchers (purchase, promotional, goodwill) are excluded from inquiry bookings - if (false === $isInquiryBooking) { - $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); - if (false === empty($purchaseVouchers)) { - $payload['gutscheine']['gutschein'] = []; - foreach ($purchaseVouchers as $code) { - $payload['gutscheine']['gutschein'][] = [ - '@einloesecode' => $code, - ]; - } - } - } - - // 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 BookingDto $bookingDto The booking data for accessing room details and quantities - */ - private function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $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> Map of room ID to participant IDs - */ - private function collectRoomMappings(BookingDto $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> Map of service ID to participant IDs - */ - private function collectServiceMappings(BookingDto $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> Map of transportation service ID to participant IDs - */ - private function collectTransportationMappings(BookingDto $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. - * - * Collects the unified pickup selection which applies to both directions. - * The API receives this as outbound pickup data. - * - * @return array> Map of pickup ID to participant IDs - */ - private function collectPickupMappings(BookingDto $bookingDto): array - { - $pickupMap = []; - - foreach ($bookingDto->participants as $index => $participant) { - $participantId = $index + 1; - - if (null !== $participant->pickup) { - $pickupMap[$participant->pickup->id][] = $participantId; - } - } - - return $pickupMap; - } - - /** - * Collects insurance mappings. - * - * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. - * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. - * - * @return array> Map of insurance ID to participant IDs - */ - private function collectInsuranceMappings(BookingDto $bookingDto): array - { - $insuranceMap = []; - - foreach ($bookingDto->participants as $index => $participant) { - $participantId = $index + 1; - - // Exclude synthetic "keine Versicherung gewünscht" option from BPN XML - if (null !== $participant->insurance && false === $participant->insurance->isNoInsurance()) { - $insuranceMap[$participant->insurance->id][] = $participantId; - } - } - - return $insuranceMap; - } - - /** - * Collects all purchase vouchers from participants. - * - * Removes duplicates and empty values, returning unique redemption codes - * for aggregation into the collection in the booking payload. - * - * IMPORTANT: Goodwill vouchers (Kulanz) are excluded from this collection. - * They are added per participant as instead. - * - * @return string[] Array of unique redemption codes (excluding Kulanz) - */ - private function collectPurchaseVouchers(BookingDto $bookingDto): array - { - $vouchers = []; - - foreach ($bookingDto->participants as $participant) { - if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { - // Exclude goodwill vouchers - if (false === $participant->hasGoodwillVoucher()) { - $vouchers[] = trim($participant->purchaseVoucherCode); - } - } - } - - // Remove duplicates (in case multiple participants enter same code) - return array_unique($vouchers); - } - - /** - * Gets the aktionscode (promotional voucher code) for a participant. - * - * Returns the promotional voucher code if present. - * Goodwill vouchers are handled separately via getGoodwillVoucherCodeForParticipant(). - * - * @return string|null The aktionscode to use, or null if none - */ - private function getPromoVoucherCodeForParticipant(ParticipantDto $participant): ?string - { - // Only return promotional voucher code (goodwill vouchers are handled separately) - if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { - return trim($participant->promoVoucherCode); - } - - return null; - } - - /** - * Extracts goodwill voucher code for a participant. - * - * Goodwill vouchers are sent as per participant (not in aggregate), - * allowing them to coexist with promotional codes. - */ - private function getGoodwillVoucherCodeForParticipant(ParticipantDto $participant): ?string - { - if ($participant->hasGoodwillVoucher() && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { - return trim($participant->purchaseVoucherCode); - } - - return null; + return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType); } /** diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php new file mode 100644 index 0000000..78ae4df --- /dev/null +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -0,0 +1,485 @@ + $bookingData->id, + 'status' => $bookingData->status, + 'idagentur' => $bookingData->agencyId, + 'idreise' => $bookingData->dateId, + 'idpartner' => $bookingData->hotelId, + 'anmelder' => $bookingData->applicant->toPayload(), + 'zahlung' => [ + '@idzahlungsart' => $bookingData->paymentId, + '@bezeichnung' => $bookingData->paymentLabel, + '@art' => $bookingData->paymentType, + ], + 'teilnehmerliste' => [ + 'teilnehmer' => [], + ], + 'zusatzleistungen' => [ + 'zusatzleistung' => [], + ], + 'beförderungen' => [ + 'beförderung' => [], + ], + 'ferienzielunterbringungen' => [ + 'ferienzielunterbringung' => [], + ], + ]; + } + + /** + * Adds bank account information to the payload if present. + * + * Bank account information is required for direct debit payments. + * + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object + */ + public function addBankAccountToPayload(array &$payload, Booking $bookingData): void + { + if (null !== $bookingData->bankAccount) { + $payload['zahlung']['bankverbindung'] = [ + '@kreditinstitut' => $bookingData->bankAccount->bankName, + '@iban' => $bookingData->bankAccount->iban, + '@bic' => $bookingData->bankAccount->bic, + '@kontoinhaber' => $bookingData->bankAccount->holder, + ]; + } + } + + /** + * Builds the participant list section of the payload. + * + * Includes status, personal data, and wishes (room remarks, license plate) for each participant. + * + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object + * @param array $participantDtos The participant DTOs from the form (for wishes data) + */ + public function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void + { + foreach ($bookingData->participants as $index => $participant) { + $participantPayload = [ + '@id' => $index + 1, + 'status' => $bookingData->participantsStatus[$index], + ...$participant->toPayload(), + ]; + + // Add wishes (room remarks, license plate) from form DTO + if (isset($participantDtos[$index])) { + $dto = $participantDtos[$index]; + if (null !== $dto->remarksRoom || null !== $dto->licensePlate) { + $wishes = []; + + if (null !== $dto->remarksRoom && '' !== trim($dto->remarksRoom)) { + $wishes['unterbringungswunsch'] = $dto->remarksRoom; + } + + if (null !== $dto->licensePlate && '' !== trim($dto->licensePlate)) { + $wishes['beförderungswunsch'] = $dto->licensePlate; + } + + if (false === empty($wishes)) { + $participantPayload['wünsche'] = $wishes; + } + } + + // Add promotional voucher as promotionalCode + $promotionalCode = $this->mappingCollector->getPromoVoucherCodeForParticipant($dto); + if (null !== $promotionalCode) { + $participantPayload['aktionscode'] = $promotionalCode; + } + + // Add goodwill voucher as participant-level einloesecode + $goodwillCode = $this->mappingCollector->getGoodwillVoucherCodeForParticipant($dto); + if (null !== $goodwillCode) { + $participantPayload['einloesecode'] = $goodwillCode; + } + } + + $payload['teilnehmerliste']['teilnehmer'][] = $participantPayload; + } + } + + /** + * Builds the services sections of the payload. + * + * Includes additional services, transportation services, and accommodation details + * with participant mappings and quantities. + * + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object + */ + public function buildServicesPayload(array &$payload, Booking $bookingData): void + { + foreach ($bookingData->additionalServices as $service) { + $payload['zusatzleistungen']['zusatzleistung'][] = [ + '@idleistung' => $service->id, + '@anzahl' => count($service->mapping), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), + ]; + } + + foreach ($bookingData->transportationServices as $service) { + $payload['beförderungen']['beförderung'][] = [ + '@idleistung' => $service->id, + '@anzahl' => count($service->mapping), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), + ]; + } + + foreach ($bookingData->rooms as $room) { + $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ + '@idzimmer' => $room->id, + '@kategorie' => $room->category, + '@idverpflegung' => $room->boardId, + '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, + '@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null, + '@anzahl' => $room->totalCount, + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)), + ]; + } + + // Add insurances with participant mappings + if (false === empty($bookingData->insurances)) { + $payload['versicherungen']['versicherung'] = []; + foreach ($bookingData->insurances as $insurance) { + if (count($insurance->mapping) > 0) { + $payload['versicherungen']['versicherung'][] = [ + '@idversicherung' => $insurance->id, + '@anzahl' => count($insurance->mapping), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)), + ]; + } + } + } + } + + /** + * Builds the pickup locations section of the payload. + * + * Only included in payload if there are actual pickup assignments for bus transportation. + * + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object + */ + public function buildPickupPayload(array &$payload, Booking $bookingData): void + { + if (0 < count($bookingData->pickupsOutbound)) { + $payload['zustiege']['zustieg'] = []; + foreach ($bookingData->pickupsOutbound as $pickup) { + $payload['zustiege']['zustieg'][] = [ + '@idzustieg' => $pickup->id, + '@anzahl' => count($pickup->mapping), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)), + ]; + } + } + } + + /** + * Builds a create booking request payload. + * + * Generates the array payload structure for creating new bookings through the BusProNet API. + * + * @param BookingDto $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 buildCreatePayload(BookingDto $bookingDto, string $bookingType): array + { + $firstParticipant = $bookingDto->participants[0]; + + $payload = [ + 'buchungsart' => $bookingType, + 'status' => $bookingDto->bookingStatus, + '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 ?? '', + ]; + + // DO NOT include personId or addressId in create mode + // BPN will automatically match existing customers by exact personal data (name, DOB, address) + // Including IDs would prevent automatic matching and could cause data inconsistencies + + 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; + } + } + + // Determine if this is an inquiry booking for voucher handling + $isInquiryBooking = 'A' === $bookingDto->bookingStatus; + + // 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 ?? '', + ]; + + // DO NOT include personId or addressId in create mode + // BPN will automatically match existing customers by exact personal data (name, DOB, address) + // Including IDs would prevent automatic matching and could cause data inconsistencies + + 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; + } + } + + // Add promotional voucher as aktionscode (excluded from inquiry bookings) + if (false === $isInquiryBooking) { + $promotionalCode = $this->mappingCollector->getPromoVoucherCodeForParticipant($participant); + if (null !== $promotionalCode) { + $participantData['aktionscode'] = $promotionalCode; + } + } + + // Add goodwill voucher as participant-level einloesecode + // Goodwill vouchers are excluded from inquiry bookings (status='A') but included in final bookings (status='F') + // They can be sent in both validation requests (buchungsart=Anfrage) and commit requests (buchungsart=Buchung) + if (false === $isInquiryBooking) { + $goodwillCode = $this->mappingCollector->getGoodwillVoucherCodeForParticipant($participant); + if (null !== $goodwillCode) { + $participantData['einloesecode'] = $goodwillCode; + } + } + + $payload['teilnehmerliste']['teilnehmer'][] = $participantData; + } + + // Collect and group all services by ID with participant mappings + $serviceMap = $this->mappingCollector->collectServiceMappings($bookingDto); + $transportationMap = $this->mappingCollector->collectTransportationMappings($bookingDto); + $roomMap = $this->mappingCollector->collectRoomMappings($bookingDto); + $pickupMap = $this->mappingCollector->collectPickupMappings($bookingDto); + $insuranceMap = $this->mappingCollector->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 purchase vouchers (regular purchase vouchers, excluding goodwill vouchers) + // All vouchers (purchase, promotional, goodwill) are excluded from inquiry bookings + if (false === $isInquiryBooking) { + $purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($bookingDto); + if (false === empty($purchaseVouchers)) { + $payload['gutscheine']['gutschein'] = []; + foreach ($purchaseVouchers as $code) { + $payload['gutscheine']['gutschein'][] = [ + '@einloesecode' => $code, + ]; + } + } + } + + // 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 + */ + public 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 BookingDto $bookingDto The booking data for accessing room details and quantities + */ + public function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $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), + ]; + } + } + + /** + * Adds purchase vouchers to the payload. + * + * @param array $payload The payload array to modify + * @param BookingDto $bookingDto The booking data + */ + public function addPurchaseVouchersToPayload(array &$payload, BookingDto $bookingDto): void + { + $purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($bookingDto); + if (false === empty($purchaseVouchers)) { + $payload['gutscheine']['gutschein'] = []; + foreach ($purchaseVouchers as $code) { + $payload['gutscheine']['gutschein'][] = [ + '@einloesecode' => $code, + ]; + } + } + } +} diff --git a/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php b/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php new file mode 100644 index 0000000..76a2071 --- /dev/null +++ b/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php @@ -0,0 +1,273 @@ +additionalServices, + ...$bookingData->transportationServices, + ...$bookingData->pickupsOutbound, + ...$bookingData->pickupsInbound, + ...$bookingData->rooms, + ...$bookingData->insurances, + ]; + + foreach ($servicesToReset as $service) { + $service->mapping = []; + } + } + + /** + * Processes all services for a single participant. + * + * This orchestrator method handles the complete service assignment workflow for one participant, + * including additional services, transportation services, pickup locations, and room assignments. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services + */ + public function processParticipantServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + if (true === $participant->isCanceled()) { + return; + } + + $this->processAdditionalServices($participant, $bookingData, $travelData); + $this->processTransportationServices($participant, $bookingData, $travelData); + $this->processPickupLocations($participant, $bookingData); + $this->processRoomAssignment($participant, $bookingData); + $this->processInsurance($participant, $bookingData, $travelData); + } + + /** + * Removes services and pickups with no participant mappings. + * + * Cleans up unused services to prevent empty services from being sent to the API. + * This includes additional services, transportation services, pickup locations, and insurances. + * + * @param Booking $bookingData The booking data object to clean up + */ + public function removeUnusedServices(Booking $bookingData): void + { + foreach ($bookingData->additionalServices as $service) { + if (0 === count($service->mapping)) { + unset($bookingData->additionalServices[$service->id]); + } + } + + foreach ($bookingData->transportationServices as $service) { + if (0 === count($service->mapping)) { + unset($bookingData->transportationServices[$service->id]); + } + } + + foreach ($bookingData->pickupsOutbound as $pickup) { + if (0 === count($pickup->mapping)) { + unset($bookingData->pickupsOutbound[$pickup->id]); + } + } + + foreach ($bookingData->pickupsInbound as $pickup) { + if (0 === count($pickup->mapping)) { + unset($bookingData->pickupsInbound[$pickup->id]); + } + } + + foreach ($bookingData->insurances as $insurance) { + if (0 === count($insurance->mapping)) { + unset($bookingData->insurances[$insurance->id]); + } + } + } + + /** + * Collects additional services from a participant for mapping. + * + * Extracts all additional services (courses, board, rentals, ski pass, rental insurance, + * additional services) from a participant into a flat array of Service objects. + * + * @param ParticipantDto $participant The participant data + * + * @return array<\App\BusProNet\Model\Service> Array of services selected by this participant + */ + private function collectParticipantAdditionalServices(ParticipantDto $participant): array + { + $services = [ + ...$participant->courses, + ...$participant->additionalServices, + ...$participant->board, + ...$participant->rentals, + ]; + + // Add ski pass if selected (single service, not an array) + if (null !== $participant->skiPass) { + $services[] = $participant->skiPass; + } + + // Add rental insurance if selected (single service, not an array) + if (null !== $participant->rentalInsurance) { + $services[] = $participant->rentalInsurance; + } + + return $services; + } + + /** + * Processes additional services for a participant. + * + * Maps additional services (courses, ski passes, board options, rentals) to the participant. + * Adds new services to the booking if they don't already exist and sets individual pricing. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services + */ + private function processAdditionalServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + $servicesToMap = $this->collectParticipantAdditionalServices($participant); + + foreach ($servicesToMap as $service) { + if (false === isset($bookingData->additionalServices[$service->id])) { + $serviceToAdd = $travelData->additionalServices[$service->id] ?? null; + if (null !== $serviceToAdd) { + $bookingData->additionalServices[$service->id] = $serviceToAdd; + $bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; + } + } + $bookingData->additionalServices[$service->id]->mapping[] = $participant->index; + } + } + + /** + * Processes transportation services for a participant. + * + * Maps transportation services (both directions: to and from destination) to the participant. + * Adds new transportation services to the booking if they don't already exist. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services + */ + private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) { + if (false === isset($bookingData->transportationServices[$service->id])) { + $serviceToAdd = $travelData->transportationServices[$service->id] ?? null; + if (null !== $serviceToAdd) { + $bookingData->transportationServices[$service->id] = $serviceToAdd; + $bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; + } + } + $bookingData->transportationServices[$service->id]->mapping[] = $participant->index; + } + } + + /** + * Processes pickup locations for participants using bus transportation. + * + * Only processes pickup locations for bus transportation services and maps the participant + * to their selected pickup location. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + */ + private function processPickupLocations(ParticipantDto $participant, Booking $bookingData): void + { + // Check if either transportation direction is BUS and pickup is selected + $hasOutboundBus = null !== $participant->transportationOutbound && 'BUS' === $participant->transportationOutbound->subType; + $hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType; + + if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) { + if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) { + $bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup; + } + $bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index; + } + } + + /** + * Processes room assignment for a participant. + * + * Maps the participant to their assigned room. Allows room reassignment during + * booking edits while maintaining the constraint that participants can only be + * assigned to room types that have already been booked. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + */ + private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void + { + if (null === $participant->assignedRoomId) { + return; + } + + // Find the room in the existing booking by ID + foreach ($bookingData->rooms as $room) { + if ($room->id === $participant->assignedRoomId) { + $room->mapping[] = $participant->index; + break; + } + } + } + + /** + * Processes travel insurance for a participant. + * + * Maps insurance to the participant. Adds new insurances to the booking if they don't exist. + * Insurance can be either individual or package-based, with automatic price-tier adjustment. + * + * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available insurances + */ + private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + // Skip if no insurance or if participant selected "keine Versicherung gewünscht" + if (null === $participant->insurance || $participant->insurance->isNoInsurance()) { + return; + } + + $insurance = $participant->insurance; + + if (false === isset($bookingData->insurances[$insurance->id])) { + $insuranceToAdd = $travelData->insurances[$insurance->id] ?? null; + if (null !== $insuranceToAdd) { + $bookingData->insurances[$insurance->id] = $insuranceToAdd; + $bookingData->insurances[$insurance->id]->individualPrice[$participant->index] = $insuranceToAdd->price; + } + } + + // Only add mapping if insurance exists in booking data + // This can legitimately be false if the insurance is no longer available in current travel data + if (isset($bookingData->insurances[$insurance->id])) { + $bookingData->insurances[$insurance->id]->mapping[] = $participant->index; + } + } +} diff --git a/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php new file mode 100644 index 0000000..38e3b70 --- /dev/null +++ b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php @@ -0,0 +1,70 @@ + $participants The participants array from the form + * @param Booking $bookingData The booking data object to update + */ + public function updateParticipantPersonalData(array $participants, Booking $bookingData): void + { + foreach ($participants as $participant) { + if ('F' !== $participant->status) { + continue; + } + + $bookingData->participants[$participant->index]->firstName = $participant->firstName; + $bookingData->participants[$participant->index]->name = $participant->lastName; + $bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth; + $bookingData->participants[$participant->index]->gender = $participant->gender; + $bookingData->participants[$participant->index]->nationality = $participant->nationality; + $bookingData->participants[$participant->index]->height = $participant->height; + $bookingData->participants[$participant->index]->weight = $participant->weight; + $bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize; + + // Update address if provided + // Create new Address instance to avoid modifying any shared object references + if (null !== $participant->address) { + $newAddress = new Address(); + $newAddress->street = $participant->address->street; + $newAddress->postCode = $participant->address->postCode; + $newAddress->city = $participant->address->city; + $newAddress->district = $participant->address->district; + $newAddress->country = $participant->address->country; + + $bookingData->participants[$participant->index]->address = $newAddress; + } + + if ($participant->email || $participant->mobile) { + if (null === $bookingData->participants[$participant->index]->communication) { + $bookingData->participants[$participant->index]->communication = new Communication(); + } + $bookingData->participants[$participant->index]->communication->email = $participant->email; + $bookingData->participants[$participant->index]->communication->mobile = $participant->mobile; + } + } + } +} diff --git a/src/BusProNet/DataProcessor/ServiceMappingCollector.php b/src/BusProNet/DataProcessor/ServiceMappingCollector.php new file mode 100644 index 0000000..dd11119 --- /dev/null +++ b/src/BusProNet/DataProcessor/ServiceMappingCollector.php @@ -0,0 +1,225 @@ +> Map of room ID to participant IDs + */ + public function collectRoomMappings(BookingDto $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> Map of service ID to participant IDs + */ + public function collectServiceMappings(BookingDto $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> Map of transportation service ID to participant IDs + */ + public function collectTransportationMappings(BookingDto $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. + * + * Collects the unified pickup selection which applies to both directions. + * The API receives this as outbound pickup data. + * + * @return array> Map of pickup ID to participant IDs + */ + public function collectPickupMappings(BookingDto $bookingDto): array + { + $pickupMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + if (null !== $participant->pickup) { + $pickupMap[$participant->pickup->id][] = $participantId; + } + } + + return $pickupMap; + } + + /** + * Collects insurance mappings. + * + * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. + * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. + * + * @return array> Map of insurance ID to participant IDs + */ + public function collectInsuranceMappings(BookingDto $bookingDto): array + { + $insuranceMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + // Exclude synthetic "keine Versicherung gewünscht" option from BPN XML + if (null !== $participant->insurance && false === $participant->insurance->isNoInsurance()) { + $insuranceMap[$participant->insurance->id][] = $participantId; + } + } + + return $insuranceMap; + } + + /** + * Collects all purchase vouchers from participants. + * + * Removes duplicates and empty values, returning unique redemption codes + * for aggregation into the collection in the booking payload. + * + * IMPORTANT: Goodwill vouchers (Kulanz) are excluded from this collection. + * They are added per participant as instead. + * + * @return string[] Array of unique redemption codes (excluding Kulanz) + */ + public function collectPurchaseVouchers(BookingDto $bookingDto): array + { + $vouchers = []; + + foreach ($bookingDto->participants as $participant) { + if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + // Exclude goodwill vouchers + if (false === $participant->hasGoodwillVoucher()) { + $vouchers[] = trim($participant->purchaseVoucherCode); + } + } + } + + // Remove duplicates (in case multiple participants enter same code) + return array_unique($vouchers); + } + + /** + * Gets the aktionscode (promotional voucher code) for a participant. + * + * Returns the promotional voucher code if present. + * Goodwill vouchers are handled separately via getGoodwillVoucherCodeForParticipant(). + * + * @return string|null The aktionscode to use, or null if none + */ + public function getPromoVoucherCodeForParticipant(ParticipantDto $participant): ?string + { + // Only return promotional voucher code (goodwill vouchers are handled separately) + if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { + return trim($participant->promoVoucherCode); + } + + return null; + } + + /** + * Extracts goodwill voucher code for a participant. + * + * Goodwill vouchers are sent as per participant (not in aggregate), + * allowing them to coexist with promotional codes. + */ + public function getGoodwillVoucherCodeForParticipant(ParticipantDto $participant): ?string + { + if ($participant->hasGoodwillVoucher() && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + return trim($participant->purchaseVoucherCode); + } + + return null; + } +} diff --git a/src/Controller/Booking/Create/Step1Controller.php b/src/Controller/Booking/Create/Step1Controller.php index f8b37f3..6757f1e 100644 --- a/src/Controller/Booking/Create/Step1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -7,6 +7,7 @@ namespace App\Controller\Booking\Create; use App\Controller\Booking\Traits\BookingCreateTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep1Type; +use App\Form\Model\BookingDto; use App\Htmx\HxTrait; use App\Service\BookingService; use App\Service\BookingSummaryDataService; @@ -66,15 +67,15 @@ class Step1Controller extends AbstractController $form->handleRequest($request); if (true === $form->isSubmitted() && true === $form->isValid()) { - if ($this->bookingService->hasRoomSelectionChanged($oldRoomSelectionSnapshot, $bookingCreateDto)) { - $this->bookingService->resetParticipantAssignments($bookingCreateDto); + if ($bookingCreateDto->hasRoomSelectionChanged($oldRoomSelectionSnapshot)) { + $bookingCreateDto->resetParticipantAssignments(); } // Check if room selection forces inquiry mode $this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto); $bookingCreateDto->currentStep = 2; - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Clear baseline snapshot when moving to step 2 $this->bookingService->clearBaselineSnapshot($request); @@ -83,21 +84,16 @@ class Step1Controller extends AbstractController } // Get complete summary data (pricing, rooms, CMS data) - $summary = $this->summaryDataService->getSummaryData($bookingCreateDto); + $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $groupedRooms = $this->bookingService->groupRoomsBySelectionType($availableRooms); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms); return $this->render('booking/create/step_1.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, - 'roomSummary' => $summary['selectedRooms'], - 'participantCount' => $summary['participantCount'], - 'pricingData' => $summary['pricingData'], - 'cmsData' => $summary['cmsData'], + 'summaryData' => $summaryData, 'form' => $form->createView(), 'groupedRooms' => $groupedRooms, - 'groupedSelectedRooms' => $groupedSelectedRooms, ]); } @@ -120,13 +116,12 @@ class Step1Controller extends AbstractController ]); $form->handleRequest($request); - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Get complete summary data (pricing, rooms, CMS data) - $summary = $this->summaryDataService->getSummaryData($bookingCreateDto); + $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $groupedRooms = $this->bookingService->groupRoomsBySelectionType($availableRooms); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms); // The DTO is now updated with the latest selection. // We can now render the blocks with the fresh data. @@ -136,11 +131,8 @@ class Step1Controller extends AbstractController [ 'form' => $form->createView(), 'bookingCreateDto' => $bookingCreateDto, - 'participantCount' => $summary['participantCount'], - 'pricingData' => $summary['pricingData'], - 'cmsData' => $summary['cmsData'], + 'summaryData' => $summaryData, 'groupedRooms' => $groupedRooms, - 'groupedSelectedRooms' => $groupedSelectedRooms, ] ); } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index bb6ff5c..1c68011 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -9,7 +9,6 @@ use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Controller\Booking\Traits\ParticipantCardFlowTrait; use App\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; -use App\Form\Model\ParticipantDto; use App\Form\Service\ParticipantFieldOptionsProvider; use App\Htmx\HxTrait; use App\Service\BookingService; @@ -66,13 +65,17 @@ class Step2Controller extends AbstractController } // Enrich with fresh availability data - $this->enrichWithFreshAvailabilities($bookingCreateDto); + $this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); - // Ensure correct number of participants - $this->ensureCorrectNumberOfParticipants($bookingCreateDto); + // Ensure correct number of participants with prepopulation callback + $this->bookingService->ensureCorrectNumberOfParticipants( + $bookingCreateDto, + $this->getUser(), + fn ($user, $participant) => $this->prepopulationService->prepopulateApplicantFromUser($user, $participant) + ); // Auto-assign rooms if needed - $this->autoAssignRoomsIfNeeded($bookingCreateDto); + $this->roomAssignmentService->assignRoomsIfNeeded($bookingCreateDto); // Preselect mandatory services $this->bookingService->preselectMandatoryServices($bookingCreateDto); @@ -107,8 +110,6 @@ class Step2Controller extends AbstractController 'bookingDto' => $bookingCreateDto, 'cardsData' => $cardsData, 'summaryData' => $summaryData, - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], ]; // HTMX request: render blocks only @@ -143,7 +144,7 @@ class Step2Controller extends AbstractController } // Enrich with fresh availability data - $this->enrichWithFreshAvailabilities($bookingDto); + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Create form with booking_context option $form = $this->createParticipantForm($bookingDto, $index); @@ -158,11 +159,7 @@ class Step2Controller extends AbstractController $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) - if (false === empty($notifications)) { - foreach ($notifications as $notification) { - $this->addFlash($notification['type'], $notification['message']); - } - } + $this->addNotificationsAsFlashMessages($notifications); // HTMX redirect to cards view return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2')); @@ -176,8 +173,6 @@ class Step2Controller extends AbstractController 'participantIndex' => $index, 'bookingDto' => $bookingDto, 'summaryData' => $summaryData, - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], 'refreshRouteName' => 'app_booking_create_step_2_participant_refresh', 'submitRouteName' => 'app_booking_create_step_2_participant', ]; @@ -224,7 +219,7 @@ class Step2Controller extends AbstractController } // Enrich with fresh availability data - $this->enrichWithFreshAvailabilities($bookingDto); + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Use trait method for refresh handling return $this->handleParticipantRefresh( @@ -235,76 +230,4 @@ class Step2Controller extends AbstractController 'app_booking_create_step_2_participant' ); } - - /** - * Ensures the booking DTO has the correct number of participant objects. - */ - private function ensureCorrectNumberOfParticipants(BookingDto $bookingCreateDto): void - { - $participantsCount = $this->getParticipantsCount($bookingCreateDto); - - $participants = $bookingCreateDto->participants; - $bookingCreateDto->participants = []; - for ($i = 0; $i < $participantsCount; ++$i) { - $participant = $participants[$i] ?? new ParticipantDto(); - $participant->index = $i; - - // Prepopulate applicant from authenticated user (index 0 only) - if (0 === $i && $this->getUser() && $this->shouldPrepopulate($participant)) { - $participant = $this->prepopulationService->prepopulateApplicantFromUser( - $this->getUser(), - $participant - ); - } - - $bookingCreateDto->participants[$i] = $participant; - } - } - - /** - * Determines if a participant should be prepopulated. - * - * Only prepopulates if the participant is "fresh" (no name set yet). - */ - private function shouldPrepopulate(ParticipantDto $participant): bool - { - return null === $participant->firstName || '' === $participant->firstName; - } - - /** - * Enriches travel data with cached availability information from BusProNet API. - */ - private function enrichWithFreshAvailabilities(BookingDto $bookingCreateDto): void - { - $dateId = $bookingCreateDto->travel->id; - - $availabilities = $this->travelDataService->getAvailabilityData($dateId, true); - - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($bookingCreateDto->travel, $availabilities); - } - } - - /** - * Automatically assigns participants to rooms if they don't have room assignments yet. - * - * Note: Auto-assignment only occurs when exactly one room type is selected. - * With multiple room types, users must manually select rooms to avoid UX issues - * with having to unselect preassigned rooms in individual participant forms. - */ - private function autoAssignRoomsIfNeeded(BookingDto $bookingCreateDto): void - { - // Check if any participants need room assignment - $needsAssignment = false; - foreach ($bookingCreateDto->participants as $participant) { - if (null === $participant->assignedRoomId) { - $needsAssignment = true; - break; - } - } - - if ($needsAssignment) { - $this->roomAssignmentService->assignParticipantsToRooms($bookingCreateDto); - } - } } diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 70625f4..fb4c1a8 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -94,7 +94,7 @@ class Step3Controller extends AbstractController // Auto-switch to inquiry mode $bookingCreateDto->bookingStatus = 'A'; $bookingCreateDto->currentStep = 4; - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); $message = 'Buchung konnte nicht validiert werden.'; if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) { @@ -147,7 +147,7 @@ class Step3Controller extends AbstractController // Validation successful - proceed to confirmation step $bookingCreateDto->currentStep = 4; - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); if ($inquiryResponse->message) { $this->addFlash('info', $inquiryResponse->message); @@ -199,7 +199,7 @@ class Step3Controller extends AbstractController ]); $form->handleRequest($request); - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); return $this->renderStepForm($bookingCreateDto, $form); } @@ -211,17 +211,11 @@ class Step3Controller extends AbstractController { // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); - $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summaryData['selectedRooms'], $availableRooms); return $this->render('booking/create/step_3.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'form' => $form->createView(), - 'participantCount' => $summaryData['participantCount'], - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], - 'assignmentCounts' => $summaryData['assignmentCounts'], - 'groupedSelectedRooms' => $groupedSelectedRooms, + 'summaryData' => $summaryData, ]); } diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 89a63e0..bda4038 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -102,7 +102,7 @@ class Step4Controller extends AbstractController // Success: Store booking number in flash and clear session $this->addFlash('booking_number', $bookingResponse->transactionNumber); $this->clearTravelDataCache($bookingCreateDto); - $this->bookingService->clearBookingCreateDto($request); + $this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE); return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success')); } catch (TimeoutException $e) { @@ -140,17 +140,11 @@ class Step4Controller extends AbstractController { // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); - $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summaryData['selectedRooms'], $availableRooms); return $this->render('booking/create/step_4.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'form' => $form->createView(), - 'participantCount' => $summaryData['participantCount'], - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], - 'assignmentCounts' => $summaryData['assignmentCounts'], - 'groupedSelectedRooms' => $groupedSelectedRooms, + 'summaryData' => $summaryData, 'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto), ]); } diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index cf24390..50c0e6e 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -5,35 +5,30 @@ declare(strict_types=1); namespace App\Controller\Booking\Edit; use App\BusProNet\ApiClient; -use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\Notification; use App\Controller\Booking\Traits; -use App\Controller\Booking\Traits\BookingDataTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Entity\User; use App\Form\BookingEditType; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantEditDto; -use App\Form\Service\ParticipantFieldHandlerRegistry; use App\Htmx\HxTrait; use App\Security\Crypt; +use App\Service\BookingEditDataLoaderService; use App\Service\BookingFingerprintService; use App\Service\BookingService; use App\Service\BookingSummaryDataService; use App\Service\ParticipantCardDataService; use App\Service\TravelDataService; -use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; -use Symfony\Contracts\Cache\CacheInterface; /** * Edit controller using card-based participant interface. @@ -46,22 +41,18 @@ use Symfony\Contracts\Cache\CacheInterface; */ class IndexController extends AbstractController { - use BookingDataTrait; use BookingExceptionHandlerTrait; use HxTrait; use Traits\ParticipantCardFlowTrait; public function __construct( private readonly ApiClient $apiClient, - private readonly BookingDataProcessor $bookingDataProcessor, + private readonly BookingEditDataLoaderService $dataLoader, private readonly TravelDataService $travelDataService, private readonly BookingService $bookingService, private readonly BookingFingerprintService $fingerprintService, private readonly BookingSummaryDataService $summaryDataService, private readonly ParticipantCardDataService $participantCardService, - private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, - private readonly CacheInterface $cache, - private readonly Security $security, private readonly Crypt $crypt, private readonly LoggerInterface $logger, ) { @@ -80,20 +71,27 @@ class IndexController extends AbstractController $password = $this->crypt->decrypt($user->getPassword()); // Load form data from session (or API on first load) - $bookingDto = $this->loadFormData($request, $id, $email, $password); + $loadResult = $this->dataLoader->loadFormData($request, $id, $email, $password); + $bookingDto = $loadResult['bookingDto']; + if (null === $bookingDto) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + return $this->redirectToRoute('app_bookings'); } + // Show staleness warning if applicable + if (null !== $loadResult['stalenessWarning']) { + $this->addFlash('info', $loadResult['stalenessWarning']); + } + // Reset staleness timer when first loading the cards view (not HTMX requests) - // This prevents false staleness warnings from old edit sessions if (false === $this->isHxRequest($request)) { - $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + $this->dataLoader->resetStalenessTimer($request, $bookingDto); } // Fetch booking data for display (surcharges, canceled status, etc.) - $bookingData = $this->fetchBookingData($email, $password, $id); + $bookingData = $this->dataLoader->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); @@ -109,56 +107,7 @@ class IndexController extends AbstractController // Handle form submission (clicking "Buchung aktualisieren") if ($form->isSubmitted() && $form->isValid()) { - // All participants validated successfully, submit to API - $this->logger->info('Initiated booking update', [ - 'email' => $email, - 'booking_id' => $id, - ]); - - try { - $response = $this->apiClient->updateBooking($bookingDto, true); - if ($response instanceof Notification) { - if (true === $response->isError()) { - $this->addFlash('error', $response->message); - } else { - $this->addFlash('info', $response->message); - } - $this->logger->error('Booking update not successful', [ - 'email' => $email, - 'booking_id' => $id, - 'message' => $response->message, - ]); - } else { - try { - $cacheKey = sprintf('bpn_booking_%d', $id); - $this->cache->delete($cacheKey); - } catch (InvalidArgumentException $e) { - } - - // Clear session on successful save - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); - - $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); - - $this->logger->info('Booking update successful', [ - 'email' => $email, - 'booking_id' => $id, - ]); - - return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); - } - } catch (TimeoutException $e) { - $this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.'); - $this->logger->error('Booking update timeout', [ - 'email' => $email, - 'booking_id' => $id, - 'exception' => $e->getMessage(), - ]); - } catch (ApiClientException $e) { - $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); - } - - return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + return $this->handleFormSubmission($request, $bookingDto, $id, $email); } // Generate card data for all participants with validation state if form was submitted and failed @@ -169,24 +118,13 @@ class IndexController extends AbstractController // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingDto); - // Group selected rooms for summary display - $availableRooms = $bookingDto->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType( - $bookingDto->getSelectedRooms(), - $availableRooms - ); - $templateData = [ 'form' => $form->createView(), 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'cardsData' => $cardsData, - 'participantsCount' => count($bookingDto->participants), - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], - 'groupedSelectedRooms' => $groupedSelectedRooms, - 'assignmentCounts' => $summaryData['assignmentCounts'], + 'summaryData' => $summaryData, 'isDirty' => $this->fingerprintService->isDirty($bookingDto), 'hasValidationErrors' => $form->isSubmitted() && false === $form->isValid(), ]; @@ -237,7 +175,7 @@ class IndexController extends AbstractController } // Fetch booking data to check for canceled status - $bookingData = $this->fetchBookingData($email, $password, $id); + $bookingData = $this->dataLoader->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); @@ -245,10 +183,9 @@ class IndexController extends AbstractController } // Check if participant is canceled - $isCanceled = ($bookingData->participantsStatus[$index] ?? null) === 'S'; + $isCanceled = 'S' === ($bookingData->participantsStatus[$index] ?? null); if ($isCanceled) { - // Redirect back to cards - canceled participants cannot be edited $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); @@ -275,11 +212,7 @@ class IndexController extends AbstractController $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) - if (false === empty($notifications)) { - foreach ($notifications as $notification) { - $this->addFlash($notification['type'], $notification['message']); - } - } + $this->addNotificationsAsFlashMessages($notifications); // Redirect back to cards return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); @@ -298,8 +231,6 @@ class IndexController extends AbstractController 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'summaryData' => $summaryData, - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'submitRouteName' => 'app_booking_edit_participant', @@ -356,13 +287,10 @@ class IndexController extends AbstractController } // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityData($bookingDto->travel->id, cached: true); - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); - } + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Fetch booking data and mutable data - $bookingData = $this->fetchBookingData($email, $password, $id); + $bookingData = $this->dataLoader->fetchBookingData($email, $password, $id); $mutableData = null !== $bookingData && !($bookingData instanceof Notification) ? $this->travelDataService->getMutabilityData($bookingData->dateId) : null; @@ -401,8 +329,6 @@ class IndexController extends AbstractController 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'summaryData' => $summaryData, - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'submitRouteName' => 'app_booking_edit_participant', @@ -461,92 +387,52 @@ class IndexController extends AbstractController } /** - * Loads form data from session or initializes from API on first load. - * - * @return BookingDto|null The form data, or null on error + * Handles form submission for booking update. */ - private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto + private function handleFormSubmission(Request $request, BookingDto $bookingDto, int $id, string $email): Response { - // Try to load from session first - $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + $this->logger->info('Initiated booking update', [ + 'email' => $email, + 'booking_id' => $id, + ]); - if (null === $formData) { - // First load: initialize from API - return $this->initializeFromApi($request, $bookingId, $email, $password); - } + try { + $response = $this->apiClient->updateBooking($bookingDto, true); + if ($response instanceof Notification) { + if (true === $response->isError()) { + $this->addFlash('error', $response->message); + } else { + $this->addFlash('info', $response->message); + } + $this->logger->error('Booking update not successful', [ + 'email' => $email, + 'booking_id' => $id, + 'message' => $response->message, + ]); + } else { + // Invalidate cache and clear session on success + $this->dataLoader->invalidateBookingCache($id); + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); - // Subsequent load: refresh from session with staleness check - return $this->refreshFromSession($formData); - } + $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); + $this->logger->info('Booking update successful', [ + 'email' => $email, + 'booking_id' => $id, + ]); - /** - * Initializes form data from API on first load and stores in session. - * - * @return BookingDto|null The form data, or null on error - */ - private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto - { - $bookingData = $this->fetchBookingData($email, $password, $bookingId); - - if (null === $bookingData || $bookingData instanceof Notification) { - $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); - - return null; - } - - $this->denyAccessUnlessGranted('EDIT', $bookingData); - - $travelData = $this->travelDataService->getTravelData($bookingData->dateId); - if (null === $travelData) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return null; - } - - $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); - - if (null === $mutableData || null === $availabilities) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return null; - } - - $this->travelDataService->patchAvailabilities($travelData, $availabilities); - $this->travelDataService->patchMutability($travelData, $mutableData); - - $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); - - // Set original fingerprint for dirty state detection - $formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true); - - $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); - - return $formData; - } - - /** - * Refreshes form data loaded from session with latest availability. - * - * @return BookingDto The refreshed form data - */ - private function refreshFromSession(BookingDto $formData): BookingDto - { - // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityData($formData->travel->id, cached: true); - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); - } - - // Show staleness warning if session is older than 5 minutes - if (null !== $formData->lastSessionUpdate) { - $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); - if ($ageInSeconds > 300) { - $minutes = (int) ceil($ageInSeconds / 60); - $this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes)); + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } + } catch (TimeoutException $e) { + $this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.'); + $this->logger->error('Booking update timeout', [ + 'email' => $email, + 'booking_id' => $id, + 'exception' => $e->getMessage(), + ]); + } catch (ApiClientException) { + $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); } - return $formData; + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } } diff --git a/src/Controller/Booking/Traits/BookingCreateTrait.php b/src/Controller/Booking/Traits/BookingCreateTrait.php index f05af34..365dc52 100644 --- a/src/Controller/Booking/Traits/BookingCreateTrait.php +++ b/src/Controller/Booking/Traits/BookingCreateTrait.php @@ -69,7 +69,7 @@ trait BookingCreateTrait { $participantsCount = $this->getParticipantsCount($bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); - $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); + $roomAssignmentCounts = $bookingCreateDto->getRoomAssignmentCounts(); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms); diff --git a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php index fb715df..4506f48 100644 --- a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php +++ b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php @@ -142,8 +142,6 @@ trait ParticipantCardFlowTrait 'participantIndex' => $index, 'bookingDto' => $bookingDto, 'summaryData' => $summaryData, - 'pricingData' => $summaryData['pricingData'], - 'cmsData' => $summaryData['cmsData'], 'refreshRouteName' => $refreshRouteName, 'submitRouteName' => $submitRouteName, ] @@ -159,6 +157,21 @@ trait ParticipantCardFlowTrait return $response; } + /** + * Converts collected notifications to flash messages. + * + * Used when redirecting after form submission, as HTMX-triggered toasts + * are destroyed on redirect. Flash messages persist across the redirect. + * + * @param array $notifications The notifications to convert + */ + private function addNotificationsAsFlashMessages(array $notifications): void + { + foreach ($notifications as $notification) { + $this->addFlash($notification['type'], $notification['message']); + } + } + /** * Required services - implementing controllers must inject these. * @@ -170,4 +183,6 @@ trait ParticipantCardFlowTrait abstract private function createForm(string $type, $data = null, array $options = []): FormInterface; abstract private function render(string $view, array $parameters = [], ?Response $response = null): Response; + + abstract protected function addFlash(string $type, mixed $message): void; } diff --git a/src/Form/Model/AcceptedVouchersDto.php b/src/Form/Model/AcceptedVouchersDto.php index 119b062..9f0b8a9 100644 --- a/src/Form/Model/AcceptedVouchersDto.php +++ b/src/Form/Model/AcceptedVouchersDto.php @@ -33,6 +33,8 @@ class AcceptedVouchersDto /** * @return array + * + * @deprecated No known usages outside tests */ public function getPromotionalVouchers(): array { @@ -41,6 +43,8 @@ class AcceptedVouchersDto /** * @return array + * + * @deprecated No known usages outside tests */ public function getPurchaseVouchers(): array { @@ -49,6 +53,8 @@ class AcceptedVouchersDto /** * @return array + * + * @deprecated No known usages outside tests */ public function getGoodwillVouchers(): array { @@ -65,6 +71,9 @@ class AcceptedVouchersDto return count($this->vouchers) > 0; } + /** + * @deprecated No known usages + */ public function count(): int { return count($this->vouchers); diff --git a/src/Form/Model/BankAccountDto.php b/src/Form/Model/BankAccountDto.php index 3437cbe..81a928b 100644 --- a/src/Form/Model/BankAccountDto.php +++ b/src/Form/Model/BankAccountDto.php @@ -46,6 +46,8 @@ class BankAccountDto /** * Returns IBAN formatted with spaces for display (e.g., DE12 3456 7890 1234 5678 90). + * + * @deprecated No known usages */ public function getFormattedIban(): ?string { @@ -60,6 +62,8 @@ class BankAccountDto /** * Returns IBAN without spaces for storage and API submission. + * + * @deprecated Only used by getFormattedIban() which is also deprecated */ public function getIbanWithoutSpaces(): ?string { diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index d89b73d..1498caa 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -161,6 +161,9 @@ class BookingDto return $this->participants; } + /** + * @deprecated Use getParticipant() and check for null instead + */ public function hasParticipant(int $index): bool { return isset($this->participants[$index]); @@ -202,11 +205,17 @@ class BookingDto return ($adults >= 1 && $adults <= 2) && ($children >= 1); } + /** + * @deprecated Use ParticipantDto::isCanceled() instead + */ public function isCanceled(): bool { return null !== $this->booking && 'S' === $this->booking->status; } + /** + * @deprecated Use ParticipantDto::isOption() instead + */ public function isOption(): bool { return null !== $this->booking && 'O' === $this->booking->status; @@ -219,6 +228,8 @@ class BookingDto * Used by templates to display the room assignment when the dropdown is hidden. * * @return string|null The room label or null if not a single room type scenario + * + * @deprecated No known usages */ public function getSingleRoomLabel(): ?string { @@ -284,6 +295,8 @@ class BookingDto * Checks if any participants have entered voucher codes. * * @return bool True if any promotional, purchase, or goodwill vouchers are present + * + * @deprecated Use AcceptedVouchersDto::hasVouchers() instead */ public function hasVouchers(): bool { @@ -317,4 +330,61 @@ class BookingDto return false; } + + /** + * Calculates the number of participants assigned to each room ID. + * + * @return array Array where key is room ID and value is count of assigned participants + */ + public function getRoomAssignmentCounts(): array + { + $counts = []; + foreach ($this->participants as $participant) { + if (null !== $participant->assignedRoomId) { + $counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1; + } + } + + return $counts; + } + + /** + * Resets all participant room assignments. + * + * Clears room assignments when room selections change to prevent + * invalid assignments. + */ + public function resetParticipantAssignments(): void + { + foreach ($this->participants as $participant) { + $participant->assignedRoomId = null; + } + } + + /** + * Creates a snapshot of the current room selection state. + * + * @return array Array of [roomId, quantity] pairs + */ + public function createRoomSelectionSnapshot(): array + { + return array_map( + fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity], + $this->roomSelections + ); + } + + /** + * Checks if room selection has changed compared to a previous snapshot. + * + * @param array $oldSnapshot The baseline room selection snapshot + * + * @return bool True if room selections have changed, false otherwise + */ + public function hasRoomSelectionChanged(array $oldSnapshot): bool + { + $newSnapshot = $this->createRoomSelectionSnapshot(); + + return $oldSnapshot !== $newSnapshot; + } } diff --git a/src/Form/Model/BookingSummaryDto.php b/src/Form/Model/BookingSummaryDto.php new file mode 100644 index 0000000..d350470 --- /dev/null +++ b/src/Form/Model/BookingSummaryDto.php @@ -0,0 +1,34 @@ + $selectedRooms Selected room DTOs from booking + * @param int $participantCount Total participant count from room capacity + * @param string $totalPrice Formatted total price (e.g., "1.234,56 €") + * @param array $groupedSelectedRooms Rooms grouped by participant assignments + * @param array $assignmentCounts Room ID to participant count mapping + * @param array $pricingData Detailed pricing breakdown + * @param array|null $cmsData CMS product data (images, etc.) + */ + public function __construct( + public readonly array $selectedRooms, + public readonly int $participantCount, + public readonly string $totalPrice, + public readonly array $groupedSelectedRooms, + public readonly array $assignmentCounts, + public readonly array $pricingData, + public readonly ?array $cmsData, + ) { + } +} diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index c974fba..deb8d5c 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -207,6 +207,9 @@ class ParticipantDto return 'S' === $this->status; } + /** + * @deprecated No known usages + */ public function isOption(): bool { return 'O' === $this->status; @@ -237,6 +240,8 @@ class ParticipantDto * Checks if the participant has selected an insurance. * * @return bool True if an insurance is selected + * + * @deprecated No known usages outside tests */ public function hasInsuranceSelected(): bool { @@ -247,6 +252,8 @@ class ParticipantDto * Gets the insurance label for display purposes. * * @return string|null The insurance label or null if no insurance selected + * + * @deprecated No known usages outside tests */ public function getInsuranceLabel(): ?string { @@ -257,6 +264,8 @@ class ParticipantDto * Gets the insurance price for pricing calculations. * * @return float The insurance price (0.0 if no insurance selected) + * + * @deprecated No known usages outside tests */ public function getInsurancePrice(): float { diff --git a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index a566594..0c9b4f8 100644 --- a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -182,4 +182,33 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle { return []; } + + /** + * Finds an item by ID from an array of objects. + * + * Generic helper for validating a selected ID against an array of available + * items and returning the matching object. Used by handlers that need to + * validate service, pickup, or other selections against available options. + * + * @param mixed $selectedId The submitted ID to find (string or int) + * @param object[] $items Array of objects with an `id` property + * + * @return object|null The matching item, or null if not found or invalid ID + */ + protected function findItemById(mixed $selectedId, array $items): ?object + { + if (null === $selectedId || (false === is_string($selectedId) && false === is_int($selectedId))) { + return null; + } + + $id = (int) $selectedId; + + foreach ($items as $item) { + if ($item->id === $id) { + return $item; + } + } + + return null; + } } diff --git a/src/Form/Service/ParticipantPickupFieldHandler.php b/src/Form/Service/ParticipantPickupFieldHandler.php index b2973b7..734dffb 100644 --- a/src/Form/Service/ParticipantPickupFieldHandler.php +++ b/src/Form/Service/ParticipantPickupFieldHandler.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Form\Service; -use App\BusProNet\Model\Pickup; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; @@ -58,36 +57,6 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler $selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName()); // Validate pickup selection against available outbound pickups - $validSelection = null; - if (null !== $selectedPickup) { - $validSelection = $this->findValidPickup($selectedPickup, $bookingDto->travel->pickupsOutbound); - } - - $participant->pickup = $validSelection; - } - - /** - * Finds a valid pickup from available pickups. - * - * @param mixed $selectedPickupId The submitted pickup ID - * @param array $availablePickups Array of available pickup locations - * - * @return Pickup|null The valid pickup object, or null if invalid - */ - private function findValidPickup(mixed $selectedPickupId, array $availablePickups): ?Pickup - { - if (null === $selectedPickupId || false === is_string($selectedPickupId) && false === is_int($selectedPickupId)) { - return null; - } - - $pickupId = (int) $selectedPickupId; - - foreach ($availablePickups as $pickup) { - if ($pickup->id === $pickupId) { - return $pickup; - } - } - - return null; + $participant->pickup = $this->findItemById($selectedPickup, $bookingDto->travel->pickupsOutbound); } } diff --git a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php index e5566fa..51c88a6 100644 --- a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Form\Service; -use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; @@ -57,43 +56,7 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi true // filter available ); - // Validate and convert selection to Service object - $validSelection = null; - if (null !== $selectedTransportation) { - $validSelection = $this->findValidTransportationService( - $selectedTransportation, - $availableServices - ); - } - - // Update participant with validated selection - $participant->transportationInbound = $validSelection; - } - - /** - * Finds a valid transportation service from available services. - * - * @param mixed $selectedServiceId The submitted service ID - * @param array $availableServices Array of available transportation services - * - * @return Service|null The valid service object, or null if invalid - */ - private function findValidTransportationService( - mixed $selectedServiceId, - array $availableServices, - ): ?Service { - if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) { - return null; - } - - $serviceId = (int) $selectedServiceId; - - foreach ($availableServices as $service) { - if ($service->id === $serviceId) { - return $service; - } - } - - return null; + // Validate and update participant with selection + $participant->transportationInbound = $this->findItemById($selectedTransportation, $availableServices); } } diff --git a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php index 1222a97..1c0b806 100644 --- a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Form\Service; -use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; @@ -57,43 +56,7 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF true // filter available ); - // Validate and convert selection to Service object - $validSelection = null; - if (null !== $selectedTransportation) { - $validSelection = $this->findValidTransportationService( - $selectedTransportation, - $availableServices - ); - } - - // Update participant with validated selection - $participant->transportationOutbound = $validSelection; - } - - /** - * Finds a valid transportation service from available services. - * - * @param mixed $selectedServiceId The submitted service ID - * @param array $availableServices Array of available transportation services - * - * @return Service|null The valid service object, or null if invalid - */ - private function findValidTransportationService( - mixed $selectedServiceId, - array $availableServices, - ): ?Service { - if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) { - return null; - } - - $serviceId = (int) $selectedServiceId; - - foreach ($availableServices as $service) { - if ($service->id === $serviceId) { - return $service; - } - } - - return null; + // Validate and update participant with selection + $participant->transportationOutbound = $this->findItemById($selectedTransportation, $availableServices); } } diff --git a/src/Service/BookingEditDataLoaderService.php b/src/Service/BookingEditDataLoaderService.php new file mode 100644 index 0000000..feec70d --- /dev/null +++ b/src/Service/BookingEditDataLoaderService.php @@ -0,0 +1,171 @@ +bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + + if (null === $formData) { + $bookingDto = $this->initializeFromApi($request, $bookingId, $email, $password); + + return [ + 'bookingDto' => $bookingDto, + 'stalenessWarning' => null, + ]; + } + + return $this->refreshFromSession($formData); + } + + /** + * Initializes booking data from API on first load and stores in session. + */ + public function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto + { + $bookingData = $this->fetchBookingData($email, $password, $bookingId); + + if (null === $bookingData || $bookingData instanceof Notification) { + return null; + } + + $travelData = $this->travelDataService->getTravelData($bookingData->dateId); + if (null === $travelData) { + return null; + } + + $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); + $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); + + if (null === $mutableData || null === $availabilities) { + return null; + } + + $this->travelDataService->patchAvailabilities($travelData, $availabilities); + $this->travelDataService->patchMutability($travelData, $mutableData); + + $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); + + // Set original fingerprint for dirty state detection + $formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true); + + $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); + + return $formData; + } + + /** + * Refreshes booking data loaded from session with latest availability. + * + * @return array{bookingDto: BookingDto, stalenessWarning: string|null} + */ + public function refreshFromSession(BookingDto $formData): array + { + // Refresh availability data + $this->travelDataService->enrichWithFreshAvailabilities($formData->travel); + + // Check for staleness + $stalenessWarning = $this->getStalenessWarning($formData); + + return [ + 'bookingDto' => $formData, + 'stalenessWarning' => $stalenessWarning, + ]; + } + + /** + * Fetches booking data from API with caching. + */ + public function fetchBookingData(string $email, string $password, int $bookingId): Booking|Notification|null + { + $cacheKey = sprintf('bpn_booking_%d', $bookingId); + try { + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $bookingId) { + $item->expiresAfter(300); + + return $this->apiClient->getBooking($email, $password, $bookingId); + }); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * Invalidates the booking cache after successful update. + */ + public function invalidateBookingCache(int $bookingId): void + { + try { + $cacheKey = sprintf('bpn_booking_%d', $bookingId); + $this->cache->delete($cacheKey); + } catch (InvalidArgumentException) { + // Ignore cache deletion errors + } + } + + /** + * Resets the staleness timer on the booking DTO. + */ + public function resetStalenessTimer(Request $request, BookingDto $bookingDto): void + { + $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + } + + /** + * Generates a staleness warning message if session is older than threshold. + */ + private function getStalenessWarning(BookingDto $formData): ?string + { + if (null === $formData->lastSessionUpdate) { + return null; + } + + $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); + + if ($ageInSeconds <= self::STALENESS_THRESHOLD_SECONDS) { + return null; + } + + $minutes = (int) ceil($ageInSeconds / 60); + + return sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes); + } +} diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index 8d6ce35..f8b780c 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -4,28 +4,21 @@ declare(strict_types=1); namespace App\Service; -use App\BusProNet\Constants; -use App\BusProNet\Model\Insurance; -use App\BusProNet\Model\Room; -use App\BusProNet\Model\Service; use App\Form\Model\BookingDto; -use App\Form\Model\ParticipantDto; /** - * Calculates pricing for booking components including rooms and services. + * Facade for booking pricing calculations. * - * This service provides comprehensive pricing calculations for the booking system, - * handling room pricing based on quantities and service pricing per participant. - * It returns structured pricing data for display in forms and summaries. + * Provides comprehensive pricing calculations for the booking system by + * coordinating specialized calculators for rooms, services, and participants. + * Returns structured pricing data for display in forms and summaries. */ class BookingPriceCalculatorService { - /** @var array Request-scoped cache for participant prices */ - private array $participantPriceCache = []; - public function __construct( - private readonly ParticipantEligibilityService $participantEligibilityService, - private readonly InsuranceService $insuranceService, + private readonly RoomPricingCalculator $roomPricingCalculator, + private readonly ServicePricingCalculator $servicePricingCalculator, + private readonly ParticipantPricingCalculator $participantPricingCalculator, ) { } @@ -38,8 +31,8 @@ class BookingPriceCalculatorService */ public function getPricingBreakdown(BookingDto $bookingDto): array { - $roomPricing = $this->calculateRoomPricing($bookingDto); - $servicePricing = $this->calculateServicePricing($bookingDto); + $roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto); + $servicePricing = $this->servicePricingCalculator->calculateServicePricing($bookingDto); $grandTotal = $this->calculateGrandTotal($bookingDto); $result = [ @@ -73,97 +66,7 @@ class BookingPriceCalculatorService */ public function calculateRoomPricing(BookingDto $bookingDto): array { - $roomPricing = []; - - // In edit mode, use room data from the booking entity - if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) { - return $this->calculateRoomPricingFromBooking($bookingDto); - } - - // In create mode, use room selections from the form - $selectedRooms = $bookingDto->getSelectedRooms(); - if (true === empty($selectedRooms)) { - return $roomPricing; - } - - foreach ($selectedRooms as $roomSelection) { - $room = $this->getRoomById($bookingDto, $roomSelection->roomId); - if (null === $room || null === $room->price) { - continue; - } - - // Calculate participant count for this room selection - $participantCount = $room->minPax * $roomSelection->quantity; - - // Each participant pays the full room price - $totalPrice = $participantCount * $room->price; - - $roomPricing[] = [ - 'roomId' => $room->id, - 'label' => $room->label, - 'quantity' => $roomSelection->quantity, - 'participantCount' => $participantCount, - 'unitPrice' => $room->price, - 'totalPrice' => $totalPrice, - ]; - } - - return $roomPricing; - } - - /** - * Calculates room pricing from booking entity data (edit mode). - * - * In edit mode, room prices come from the booking entity's individualPrice arrays. - * Each participant has their room price stored in the room's individualPrice array. - * - * @param BookingDto $bookingDto The booking data with booking entity - * - * @return array Array of room pricing data with labels, quantities, and totals - */ - private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array - { - $roomPricing = []; - $roomGroups = []; - - // Group participants by room and sum their individual prices - foreach ($bookingDto->booking->rooms as $room) { - if (false === isset($roomGroups[$room->id])) { - $roomGroups[$room->id] = [ - 'room' => $room, - 'participantCount' => 0, - 'totalPrice' => 0.0, - ]; - } - - // Sum individual prices for all participants in this room - foreach ($room->mapping as $participantIndex) { - $individualPrice = $room->individualPrice[$participantIndex] ?? 0.0; - $roomGroups[$room->id]['totalPrice'] += $individualPrice; - ++$roomGroups[$room->id]['participantCount']; - } - } - - // Build pricing array - foreach ($roomGroups as $roomId => $data) { - $room = $data['room']; - $participantCount = $data['participantCount']; - $totalPrice = $data['totalPrice']; - - // Calculate average unit price (price per person) - $unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; - - $roomPricing[] = [ - 'roomId' => $room->id, - 'label' => $room->label, - 'quantity' => $room->totalCount, - 'participantCount' => $participantCount, - 'unitPrice' => $unitPrice, - 'totalPrice' => $totalPrice, - ]; - } - - return $roomPricing; + return $this->roomPricingCalculator->calculateRoomPricing($bookingDto); } /** @@ -177,32 +80,7 @@ class BookingPriceCalculatorService */ public function calculateServicePricing(BookingDto $bookingDto): array { - $participants = $bookingDto->getParticipants(); - - if (true === empty($participants)) { - return []; - } - - // Aggregate service selections across all eligible participants - $serviceAggregation = []; - - foreach ($participants as $participantIndex => $participant) { - // Skip ineligible participants (no skipasses available for their age) - if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { - continue; - } - - $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); - } - - // Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz) - $transportationItems = $this->aggregateTransportationServices($bookingDto); - foreach ($transportationItems as $key => $transportationItem) { - $serviceAggregation[$key] = $transportationItem; - } - - // Group services by subtype and convert to pricing format - return $this->groupServicesBySubtype($serviceAggregation); + return $this->servicePricingCalculator->calculateServicePricing($bookingDto); } /** @@ -214,8 +92,8 @@ class BookingPriceCalculatorService */ public function calculateGrandTotal(BookingDto $bookingDto): float { - $roomTotal = $this->calculateRoomTotal($bookingDto); - $serviceTotal = $this->calculateServiceTotal($bookingDto); + $roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto); + $serviceTotal = $this->servicePricingCalculator->calculateServiceTotal($bookingDto); return $roomTotal + $serviceTotal; } @@ -225,9 +103,7 @@ class BookingPriceCalculatorService */ public function calculateRoomTotal(BookingDto $bookingDto): float { - $roomPricing = $this->calculateRoomPricing($bookingDto); - - return array_sum(array_column($roomPricing, 'totalPrice')); + return $this->roomPricingCalculator->calculateRoomTotal($bookingDto); } /** @@ -235,9 +111,7 @@ class BookingPriceCalculatorService */ public function calculateServiceTotal(BookingDto $bookingDto): float { - $servicePricing = $this->calculateServicePricing($bookingDto); - - return array_sum(array_column($servicePricing, 'groupTotal')); + return $this->servicePricingCalculator->calculateServiceTotal($bookingDto); } /** @@ -302,26 +176,7 @@ class BookingPriceCalculatorService */ public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float { - $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant) { - return 0.0; - } - - $totalPrice = 0.0; - - // Add room price if participant is assigned to a room - if (null !== $participant->assignedRoomId) { - $room = $this->getRoomById($bookingDto, $participant->assignedRoomId); - if (null !== $room && null !== $room->price) { - // Each participant pays the full room price - $totalPrice += $room->price; - } - } - - // Add service prices for this participant (with booking context for bulk insurance) - $totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto); - - return $totalPrice; + return $this->participantPricingCalculator->calculateIndividualParticipantPrice($bookingDto, $participantIndex); } /** @@ -333,14 +188,7 @@ class BookingPriceCalculatorService */ public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array { - $participantPrices = []; - $participants = $bookingDto->getParticipants(); - - foreach ($participants as $index => $participant) { - $participantPrices[$index] = $this->calculateIndividualParticipantPrice($bookingDto, $index); - } - - return $participantPrices; + return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto); } /** @@ -350,7 +198,7 @@ class BookingPriceCalculatorService * where insurance selection affects travel price which affects insurance eligibility. * * Only includes services where versicherungsberechnung='J' in the XML. Services with - * versicherungsberechnung='N' (like CO² compensation) are excluded from the calculation + * versicherungsberechnung='N' (like CO2 compensation) are excluded from the calculation * as per BPN API requirements for insurance tier determination. * * @param BookingDto $bookingDto The booking data containing all participants @@ -362,538 +210,9 @@ class BookingPriceCalculatorService BookingDto $bookingDto, int $participantIndex, ): float { - $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant) { - return 0.0; - } - - // Generate cache key based on participant state that affects pricing - $stateComponents = [ - 'room' => $participant->assignedRoomId ?? 'none', - 'skiPass' => $participant->skiPass?->id ?? 'none', - 'rentalInsurance' => $participant->rentalInsurance?->id ?? 'none', - 'transportationOut' => $participant->transportationOutbound?->id ?? 'none', - 'transportationIn' => $participant->transportationInbound?->id ?? 'none', - 'pickup' => $participant->pickup?->id ?? 'none', - 'parking' => $participant->parkingService?->id ?? 'none', - 'courses' => implode('_', array_map(fn ($s) => $s->id, $participant->courses ?? [])), - 'additionalServices' => implode('_', array_map(fn ($s) => $s->id, $participant->additionalServices ?? [])), - 'board' => implode('_', array_map(fn ($s) => $s->id, $participant->board ?? [])), - 'rentals' => implode('_', array_map(fn ($s) => $s->id, $participant->rentals ?? [])), - ]; - - $cacheKey = sprintf( - 'participant_price_%d_%s', - $participantIndex, - md5(json_encode($stateComponents)) - ); - - return $this->participantPriceCache[$cacheKey] ??= (function () use ($bookingDto, $participant) { - $totalPrice = 0.0; - - // Add room price if participant is assigned to a room - if (null !== $participant->assignedRoomId) { - $room = $this->getRoomById($bookingDto, $participant->assignedRoomId); - if (null !== $room && null !== $room->price) { - // Each participant pays the full room price - $totalPrice += $room->price; - } - } - - // Add service prices for this participant (excluding insurance) - // For insurance eligibility calculation, only include services marked with versicherungsberechnung='J' - $totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true); - - return $totalPrice; - })(); - } - - /** - * Aggregates all transportation-related services and pricing into separate line items. - * - * Creates separate entries for: - * - Beförderung: Sum of all positive pickup prices and base transportation costs - * - Beförderung - Rabatt: Sum of all negative transportation prices (discounts) - * - Parkplatz: Sum of all parking service prices - * - * Only includes transportation costs from eligible participants. - * - * @param BookingDto $bookingDto The booking data containing participants - * - * @return array Array of transportation line items (Beförderung, Rabatt, Parkplatz) - */ - private function aggregateTransportationServices(BookingDto $bookingDto): array - { - $participants = $bookingDto->getParticipants(); - - $transportationPositiveTotal = 0.0; // Positive transportation/pickup costs - $transportationDiscountTotal = 0.0; // Negative transportation prices (discounts) - $parkingTotal = 0.0; // Parking service costs - - $transportationParticipants = 0; - $discountParticipants = 0; - $parkingParticipants = 0; - - foreach ($participants as $participantIndex => $participant) { - // Skip ineligible participants (no skipasses available for their age) - if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { - continue; - } - - $participantTransportationPositiveCost = 0.0; - $participantTransportationDiscountCost = 0.0; - $participantParkingCost = 0.0; - - // Transportation service pricing (outbound) - if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { - if ($participant->transportationOutbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationOutbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationOutbound->price; - } - } - - // Transportation service pricing (inbound) - if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { - if ($participant->transportationInbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationInbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationInbound->price; - } - } - - // Pickup pricing (unified for both directions) - if (null !== $participant->pickup && null !== $participant->pickup->price) { - if ($participant->pickup->price < 0) { - $participantTransportationDiscountCost += $participant->pickup->price; - } else { - $participantTransportationPositiveCost += $participant->pickup->price; - } - } - - // Parking service pricing - if (null !== $participant->parkingService && null !== $participant->parkingService->price) { - $participantParkingCost += $participant->parkingService->price; - } - - // Aggregate participant totals - if ($participantTransportationPositiveCost > 0) { - $transportationPositiveTotal += $participantTransportationPositiveCost; - ++$transportationParticipants; - } - if ($participantTransportationDiscountCost < 0) { - $transportationDiscountTotal += $participantTransportationDiscountCost; - ++$discountParticipants; - } - if ($participantParkingCost > 0) { - $parkingTotal += $participantParkingCost; - ++$parkingParticipants; - } - } - - $transportationItems = []; - - // Add transportation entry (only positive costs) - if ($transportationPositiveTotal > 0) { - $transportationItems['transportation_positive'] = [ - 'serviceId' => 'transportation_positive', - 'label' => 'Beförderung', - 'unitPrice' => null, - 'participantCount' => $transportationParticipants, - 'totalPrice' => $transportationPositiveTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, - ]; - } - - // Add discount entry (only negative costs) - if ($transportationDiscountTotal < 0) { - $transportationItems['transportation_discount'] = [ - 'serviceId' => 'transportation_discount', - 'label' => 'Beförderung - Rabatt', - 'unitPrice' => null, - 'participantCount' => $discountParticipants, - 'totalPrice' => $transportationDiscountTotal, - 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', - ]; - } - - // Add parking entry (only if positive costs) - if ($parkingTotal > 0) { - $transportationItems['transportation_parking'] = [ - 'serviceId' => 'transportation_parking', - 'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], - 'unitPrice' => null, - 'participantCount' => $parkingParticipants, - 'totalPrice' => $parkingTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, - ]; - } - - return $transportationItems; - } - - /** - * Groups services by their subtypes for display, separating positive costs and discounts. - * - * Creates separate entries for positive costs and negative costs (discounts) within each service group. - * For example: "Kurse" and "Kurse - Rabatt" if there are both positive and negative priced course services. - * - * @param array $serviceAggregation Aggregated service data - * - * @return array Grouped services by subtype with separate discount entries - */ - private function groupServicesBySubtype(array $serviceAggregation): array - { - $groupedServices = []; - - // First pass: separate positive and negative prices by subtype - $servicesBySubtypeAndSign = []; - - foreach ($serviceAggregation as $serviceData) { - if (0.0 === $serviceData['totalPrice']) { - continue; // Skip zero-price services - } - - $subType = $serviceData['subType'] ?? 'other'; - - // Normalize rental subtypes to avoid duplicate sections - if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { - $subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key - } - - // Normalize insurance subtypes to avoid duplicate sections - if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { - $subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key - } - - $isDiscount = $serviceData['totalPrice'] < 0; - - // Create separate buckets for positive costs and discounts - $bucketKey = $subType.($isDiscount ? '_discount' : '_regular'); - - if (false === isset($servicesBySubtypeAndSign[$bucketKey])) { - $servicesBySubtypeAndSign[$bucketKey] = [ - 'subType' => $subType, - 'isDiscount' => $isDiscount, - 'services' => [], - 'total' => 0.0, - 'participantCount' => 0, - ]; - } - - $servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData; - $servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice']; - $servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount']; - } - - // Second pass: create display groups - foreach ($servicesBySubtypeAndSign as $bucketData) { - $baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']); - $groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName; - - $groupedServices[] = [ - 'groupName' => $groupName, - 'services' => $bucketData['services'], - 'groupTotal' => $bucketData['total'], - ]; - } - - return $groupedServices; - } - - /** - * Maps service subtypes to user-friendly group names. - */ - private function getGroupNameForSubtype(string $subType): string - { - // Handle rentals array (keep for backward compatibility with non-normalized subtypes) - if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { - return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS]; - } - - return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen'; - } - - /** - * Aggregates service selections from a single participant into the service aggregation array. - */ - private function aggregateParticipantServices( - ParticipantDto $participant, - array &$serviceAggregation, - BookingDto $bookingDto, - ): void { - // Handle single service selections (skiPass, rentalInsurance) - if (null !== $participant->skiPass && null !== $participant->skiPass->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1); - } - - if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); - } - - // Resolve insurance: use price-tier-adjusted insurance for bulk assignment - $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); - - if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) { - $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); - } - - // Handle multiple service selections - $multipleServiceArrays = [ - 'courses' => $participant->courses, - 'additionalServices' => $participant->additionalServices, - 'board' => $participant->board, - 'rentals' => $participant->rentals, - ]; - - foreach ($multipleServiceArrays as $serviceArray) { - if (true === is_array($serviceArray)) { - foreach ($serviceArray as $service) { - if ($service instanceof Service && null !== $service->price) { - $this->addToServiceAggregation($serviceAggregation, $service, 1); - } - } - } - } - } - - /** - * Adds a service to the aggregation array, incrementing count and updating total price. - */ - private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): void - { - $serviceKey = $service->id.'_'.$service->label; - - if (false === isset($serviceAggregation[$serviceKey])) { - $serviceAggregation[$serviceKey] = [ - 'serviceId' => $service->id, - 'label' => $service->label, - 'unitPrice' => $service->price, - 'participantCount' => 0, - 'totalPrice' => 0.0, - 'subType' => $service->subType, - ]; - } - - $serviceAggregation[$serviceKey]['participantCount'] += $quantity; - $serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity; - } - - /** - * Calculates the total service cost for a single participant. - * - * @param ParticipantDto $participant The participant to calculate services for - * @param bool $includeInsurance Whether to include insurance pricing (default: true) - * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution - * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) - * - * @return float The total service cost for this participant - */ - private function calculateParticipantServiceTotal( - ParticipantDto $participant, - bool $includeInsurance = true, - ?BookingDto $bookingDto = null, - bool $onlyInsuranceCalculationServices = false, - ): float { - $serviceTotal = 0.0; - - // Single service selections - if (null !== $participant->skiPass && null !== $participant->skiPass->price) { - if (false === $onlyInsuranceCalculationServices || true === $participant->skiPass->includeInInsuranceCalculation) { - $serviceTotal += $participant->skiPass->price; - } - } - - if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { - if (false === $onlyInsuranceCalculationServices || true === $participant->rentalInsurance->includeInInsuranceCalculation) { - $serviceTotal += $participant->rentalInsurance->price; - } - } - - // Get effective insurance (considering bulk insurance for dependent participants) - $effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto); - if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) { - $serviceTotal += $effectiveInsurance->price; - } - - // Transportation services - if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { - if (false === $onlyInsuranceCalculationServices || true === $participant->transportationOutbound->includeInInsuranceCalculation) { - $serviceTotal += $participant->transportationOutbound->price; - } - } - - if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { - if (false === $onlyInsuranceCalculationServices || true === $participant->transportationInbound->includeInInsuranceCalculation) { - $serviceTotal += $participant->transportationInbound->price; - } - } - - if (null !== $participant->pickup && null !== $participant->pickup->price) { - $serviceTotal += $participant->pickup->price; - } - - if (null !== $participant->parkingService && null !== $participant->parkingService->price) { - if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) { - $serviceTotal += $participant->parkingService->price; - } - } - - // Multiple service selections - $multipleServiceArrays = [ - 'courses' => $participant->courses, - 'additionalServices' => $participant->additionalServices, - 'board' => $participant->board, - 'rentals' => $participant->rentals, - ]; - - foreach ($multipleServiceArrays as $serviceArray) { - if (true === is_array($serviceArray)) { - foreach ($serviceArray as $service) { - if ($service instanceof Service && null !== $service->price) { - if (false === $onlyInsuranceCalculationServices || true === $service->includeInInsuranceCalculation) { - $serviceTotal += $service->price; - } - } - } - } - } - - return $serviceTotal; - } - - /** - * Retrieves a room by ID from the booking's travel data. - */ - private function getRoomById(BookingDto $bookingDto, ?int $roomId): ?Room - { - if (null === $roomId) { - return null; - } - - foreach ($bookingDto->travel->rooms as $room) { - if ($room->id === $roomId) { - return $room; - } - } - - return null; - } - - /** - * Adds an insurance to the service aggregation array. - * - * @param array $serviceAggregation The service aggregation array to update - * @param Insurance $insurance The insurance to add - * @param int $quantity The quantity of the insurance - */ - private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void - { - $serviceKey = $insurance->id.'_'.$insurance->label; - - if (false === isset($serviceAggregation[$serviceKey])) { - $serviceAggregation[$serviceKey] = [ - 'serviceId' => $insurance->id, - 'label' => $insurance->label, - 'unitPrice' => $insurance->price, - 'participantCount' => 0, - 'totalPrice' => 0.0, - 'subType' => $insurance->getSubType(), - ]; - } - - $serviceAggregation[$serviceKey]['participantCount'] += $quantity; - $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; - } - - /** - * Gets the effective insurance for a participant, considering bulk insurance assignment. - * - * When bulk insurance is active and the participant is a dependent (index > 0), - * returns the applicant's insurance. Otherwise returns the participant's own insurance. - * - * This method is used for pricing calculations to show correct prices when bulk - * insurance is enabled, even though the actual assignment happens in the processor. - * - * @param ParticipantDto $participant The participant to get insurance for - * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check - * - * @return Insurance|null The effective insurance for pricing purposes - */ - private function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance - { - // If no booking context, use participant's own insurance - if (null === $bookingDto) { - return $participant->insurance; - } - - // Applicant always uses their own insurance - if (0 === $participant->index) { - return $participant->insurance; - } - - // Check if bulk insurance is active - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant || false === $applicant->bulkInsuranceBooking) { - return $participant->insurance; - } - - // Bulk insurance is active - use applicant's insurance for dependent participants - return $applicant->insurance; - } - - /** - * Resolves the insurance to use for aggregation, handling bulk insurance with price tiers. - * - * When bulk insurance is active, dependent participants get price-tier-adjusted insurance - * based on their individual travel price, matching the logic used in API submission. - * - * @param ParticipantDto $participant The participant to resolve insurance for - * @param BookingDto $bookingDto The booking context - * - * @return Insurance|null The insurance to aggregate (null if none) - */ - private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance - { - // If participant already has insurance assigned, use it - if (null !== $participant->insurance) { - return $participant->insurance; - } - - // Check if bulk insurance is active - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { - return null; // No bulk insurance active - } - - // Applicant always uses their own insurance - if (0 === $participant->index) { - return $participant->insurance; - } - - // For dependent participants: calculate price-tier-adjusted insurance - // This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive() - // Get selectable (non-complementary) insurances with caching - $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - - // Get insurances of the same type as applicant's selection - $sameTypeInsurances = $this->insuranceService->filterByType( - $selectableInsurances, - $applicant->insurance - ); - - // Calculate travel price for eligibility checks - $travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index); - - // Get eligible insurances for THIS participant (price tier adjusted) - $eligibleInsurances = $this->insuranceService->getEligibleInsurances( - $sameTypeInsurances, - $participant, + return $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( $bookingDto, - $travelPrice + $participantIndex ); - - // Return first eligible insurance (sorted by price) - return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; } } diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 13819ab..308a92b 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -8,6 +8,7 @@ use App\BusProNet\Model\Travel; use App\Exception\BookingSessionNotFoundException; use App\Exception\NoRoomsAvailableException; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -37,7 +38,7 @@ class BookingService $baselineKey = self::BOOKING_CREATE_BASELINE_KEY; if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { - $baseline = $this->createRoomSelectionSnapshot($bookingCreateDto); + $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); $request->getSession()->set($baselineKey, $baseline); return $baseline; @@ -143,31 +144,6 @@ class BookingService return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY; } - /** - * Saves the booking creation DTO to the session. - * - * Persists the current booking state to the session for retrieval - * across multiple HTTP requests during the booking flow. - * - * @param Request $request The HTTP request with session - * @param BookingDto $bookingCreateDto The booking DTO to persist - */ - public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void - { - $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - } - - /** - * 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. * @@ -229,7 +205,7 @@ class BookingService $bookingCreateDto->agencyId = $agencyId; $bookingCreateDto->bookingStatus = $bookingStatus; - $this->saveBookingCreateDto($request, $bookingCreateDto); + $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); return $bookingCreateDto; } @@ -286,17 +262,12 @@ class BookingService * Calculates the number of participants assigned to each room ID. * * @return array an array where the key is the room ID and the value is the count of assigned participants + * + * @deprecated Use BookingDto::getRoomAssignmentCounts() instead */ public function getRoomAssignmentCounts(BookingDto $bookingDto): array { - $counts = []; - foreach ($bookingDto->getParticipants() as $participant) { - if (null !== $participant->assignedRoomId) { - $counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1; - } - } - - return $counts; + return $bookingDto->getRoomAssignmentCounts(); } /** @@ -380,48 +351,40 @@ class BookingService /** * Resets all participant room assignments in the DTO. * - * Clears room assignments when room selections change to prevent - * invalid assignments. Called when users modify their room selections - * in step 1 to ensure participants are reassigned appropriately. - * * @param BookingDto $dto The booking DTO to reset assignments for + * + * @deprecated Use BookingDto::resetParticipantAssignments() instead */ public function resetParticipantAssignments(BookingDto $dto): void { - foreach ($dto->participants as $participant) { - $participant->assignedRoomId = null; - } + $dto->resetParticipantAssignments(); } /** * Creates a snapshot of the current room selection state. * * @return array Array of [roomId, quantity] pairs + * + * @deprecated Use BookingDto::createRoomSelectionSnapshot() instead */ public function createRoomSelectionSnapshot(BookingDto $dto): array { - return array_map( - fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity], - $dto->roomSelections - ); + return $dto->createRoomSelectionSnapshot(); } /** * Checks if room selection has changed compared to a previous snapshot. * - * Compares the current room selection state with a baseline snapshot - * to detect changes that would require participant reassignment. - * * @param array $oldSnapshot The baseline room selection snapshot * @param BookingDto $newDto The current booking DTO * * @return bool True if room selections have changed, false otherwise + * + * @deprecated Use BookingDto::hasRoomSelectionChanged() instead */ public function hasRoomSelectionChanged(array $oldSnapshot, BookingDto $newDto): bool { - $newSnapshot = $this->createRoomSelectionSnapshot($newDto); - - return $oldSnapshot !== $newSnapshot; + return $newDto->hasRoomSelectionChanged($oldSnapshot); } /** @@ -545,4 +508,48 @@ class BookingService } } } + + /** + * Ensures the booking DTO has the correct number of participant objects. + * + * Creates or removes ParticipantDto objects based on room selections. + * Preserves existing participant data when adjusting the count. + * Optionally prepopulates the applicant (index 0) from an authenticated user. + * + * @param BookingDto $bookingDto The booking DTO to update + * @param \Symfony\Component\Security\Core\User\UserInterface|null $user Optional authenticated user for prepopulation + * @param callable|null $prepopulateCallback Callback to prepopulate applicant: fn(UserInterface, ParticipantDto): ParticipantDto + */ + public function ensureCorrectNumberOfParticipants( + BookingDto $bookingDto, + ?\Symfony\Component\Security\Core\User\UserInterface $user = null, + ?callable $prepopulateCallback = null, + ): void { + $participantsCount = $this->getParticipantsCount($bookingDto->roomSelections, $bookingDto->travel); + + $existingParticipants = $bookingDto->participants; + $bookingDto->participants = []; + + for ($i = 0; $i < $participantsCount; ++$i) { + $participant = $existingParticipants[$i] ?? new ParticipantDto(); + $participant->index = $i; + + // Prepopulate applicant from authenticated user (index 0 only) + if (0 === $i && null !== $user && null !== $prepopulateCallback && $this->shouldPrepopulate($participant)) { + $participant = $prepopulateCallback($user, $participant); + } + + $bookingDto->participants[$i] = $participant; + } + } + + /** + * Determines if a participant should be prepopulated. + * + * Only prepopulates if the participant is "fresh" (no name set yet). + */ + private function shouldPrepopulate(ParticipantDto $participant): bool + { + return null === $participant->firstName || '' === $participant->firstName; + } } diff --git a/src/Service/BookingSummaryDataService.php b/src/Service/BookingSummaryDataService.php index 04db820..8f47351 100644 --- a/src/Service/BookingSummaryDataService.php +++ b/src/Service/BookingSummaryDataService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Form\Model\BookingDto; +use App\Form\Model\BookingSummaryDto; use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -27,18 +28,8 @@ class BookingSummaryDataService /** * Get complete summary data for booking sidebar. - * - * @return array{ - * selectedRooms: array, - * participantCount: int, - * totalPrice: string, - * groupedSelectedRooms: array, - * assignmentCounts: array, - * pricingData: array, - * cmsData: array|null - * } */ - public function getSummaryData(BookingDto $bookingDto): array + public function getSummaryData(BookingDto $bookingDto): BookingSummaryDto { // Get selected rooms (for Step1 controller compatibility) $selectedRooms = $bookingDto->getSelectedRooms(); @@ -57,7 +48,7 @@ class BookingSummaryDataService } } - // Group selected rooms with counts + // Group selected rooms with counts (for display) $groupedSelectedRooms = []; foreach ($roomCounts as $roomId => $count) { $room = $bookingDto->travel->getRoomById($roomId); @@ -78,15 +69,15 @@ class BookingSummaryDataService // Calculate participant count from room capacity (source of truth) $participantCount = $this->calculateParticipantCountFromRooms($bookingDto); - return [ - 'selectedRooms' => $selectedRooms, - 'participantCount' => $participantCount, - 'totalPrice' => number_format($totalPrice, 2, ',', '.').' €', - 'groupedSelectedRooms' => $groupedSelectedRooms, - 'assignmentCounts' => $roomCounts, - 'pricingData' => $pricingData, - 'cmsData' => $cmsData, - ]; + return new BookingSummaryDto( + selectedRooms: $selectedRooms, + participantCount: $participantCount, + totalPrice: number_format($totalPrice, 2, ',', '.').' €', + groupedSelectedRooms: $groupedSelectedRooms, + assignmentCounts: $roomCounts, + pricingData: $pricingData, + cmsData: $cmsData, + ); } /** diff --git a/src/Service/ParticipantPricingCalculator.php b/src/Service/ParticipantPricingCalculator.php new file mode 100644 index 0000000..5fe592b --- /dev/null +++ b/src/Service/ParticipantPricingCalculator.php @@ -0,0 +1,265 @@ + Request-scoped cache for participant prices */ + private array $participantPriceCache = []; + + public function __construct( + private readonly RoomPricingCalculator $roomPricingCalculator, + ) { + } + + /** + * Calculates the total price for an individual participant. + * + * This method calculates the complete price breakdown for a single participant, + * including their room allocation (full room price) and all selected services. + * + * @param BookingDto $bookingDto The booking data containing all participants + * @param int $participantIndex The index of the participant to calculate for + * + * @return float The total price for the specified participant + */ + public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return 0.0; + } + + $totalPrice = 0.0; + + // Add room price if participant is assigned to a room + if (null !== $participant->assignedRoomId) { + $room = $this->roomPricingCalculator->getRoomById($bookingDto, $participant->assignedRoomId); + if (null !== $room && null !== $room->price) { + // Each participant pays the full room price + $totalPrice += $room->price; + } + } + + // Add service prices for this participant (with booking context for bulk insurance) + $totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto); + + return $totalPrice; + } + + /** + * Calculates individual prices for all participants in a booking. + * + * @param BookingDto $bookingDto The booking data containing all participants + * + * @return array Array indexed by participant index containing individual prices + */ + public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array + { + $participantPrices = []; + $participants = $bookingDto->getParticipants(); + + foreach ($participants as $index => $participant) { + $participantPrices[$index] = $this->calculateIndividualParticipantPrice($bookingDto, $index); + } + + return $participantPrices; + } + + /** + * Calculates the total price for an individual participant excluding insurance. + * + * This method is used for insurance eligibility filtering to avoid circular dependency + * where insurance selection affects travel price which affects insurance eligibility. + * + * Only includes services where versicherungsberechnung='J' in the XML. Services with + * versicherungsberechnung='N' (like CO2 compensation) are excluded from the calculation + * as per BPN API requirements for insurance tier determination. + * + * @param BookingDto $bookingDto The booking data containing all participants + * @param int $participantIndex The index of the participant to calculate for + * + * @return float The total price for the specified participant excluding insurance and non-calculated services + */ + public function calculateIndividualParticipantPriceExcludingInsurance( + BookingDto $bookingDto, + int $participantIndex, + ): float { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return 0.0; + } + + // Generate cache key based on participant state that affects pricing + $stateComponents = [ + 'room' => $participant->assignedRoomId ?? 'none', + 'skiPass' => $participant->skiPass?->id ?? 'none', + 'rentalInsurance' => $participant->rentalInsurance?->id ?? 'none', + 'transportationOut' => $participant->transportationOutbound?->id ?? 'none', + 'transportationIn' => $participant->transportationInbound?->id ?? 'none', + 'pickup' => $participant->pickup?->id ?? 'none', + 'parking' => $participant->parkingService?->id ?? 'none', + 'courses' => implode('_', array_map(fn ($s) => $s->id, $participant->courses ?? [])), + 'additionalServices' => implode('_', array_map(fn ($s) => $s->id, $participant->additionalServices ?? [])), + 'board' => implode('_', array_map(fn ($s) => $s->id, $participant->board ?? [])), + 'rentals' => implode('_', array_map(fn ($s) => $s->id, $participant->rentals ?? [])), + ]; + + $cacheKey = sprintf( + 'participant_price_%d_%s', + $participantIndex, + md5(json_encode($stateComponents)) + ); + + return $this->participantPriceCache[$cacheKey] ??= (function () use ($bookingDto, $participant) { + $totalPrice = 0.0; + + // Add room price if participant is assigned to a room + if (null !== $participant->assignedRoomId) { + $room = $this->roomPricingCalculator->getRoomById($bookingDto, $participant->assignedRoomId); + if (null !== $room && null !== $room->price) { + // Each participant pays the full room price + $totalPrice += $room->price; + } + } + + // Add service prices for this participant (excluding insurance) + // For insurance eligibility calculation, only include services marked with versicherungsberechnung='J' + $totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true); + + return $totalPrice; + })(); + } + + /** + * Gets the effective insurance for a participant, considering bulk insurance assignment. + * + * When bulk insurance is active and the participant is a dependent (index > 0), + * returns the applicant's insurance. Otherwise returns the participant's own insurance. + * + * This method is used for pricing calculations to show correct prices when bulk + * insurance is enabled, even though the actual assignment happens in the processor. + * + * @param ParticipantDto $participant The participant to get insurance for + * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check + * + * @return Insurance|null The effective insurance for pricing purposes + */ + public function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance + { + // If no booking context, use participant's own insurance + if (null === $bookingDto) { + return $participant->insurance; + } + + // Applicant always uses their own insurance + if (0 === $participant->index) { + return $participant->insurance; + } + + // Check if bulk insurance is active + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant || false === $applicant->bulkInsuranceBooking) { + return $participant->insurance; + } + + // Bulk insurance is active - use applicant's insurance for dependent participants + return $applicant->insurance; + } + + /** + * Calculates the total service cost for a single participant. + * + * @param ParticipantDto $participant The participant to calculate services for + * @param bool $includeInsurance Whether to include insurance pricing (default: true) + * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution + * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) + * + * @return float The total service cost for this participant + */ + public function calculateParticipantServiceTotal( + ParticipantDto $participant, + bool $includeInsurance = true, + ?BookingDto $bookingDto = null, + bool $onlyInsuranceCalculationServices = false, + ): float { + $serviceTotal = 0.0; + + // Single service selections + if (null !== $participant->skiPass && null !== $participant->skiPass->price) { + if (false === $onlyInsuranceCalculationServices || true === $participant->skiPass->includeInInsuranceCalculation) { + $serviceTotal += $participant->skiPass->price; + } + } + + if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { + if (false === $onlyInsuranceCalculationServices || true === $participant->rentalInsurance->includeInInsuranceCalculation) { + $serviceTotal += $participant->rentalInsurance->price; + } + } + + // Get effective insurance (considering bulk insurance for dependent participants) + $effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto); + if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) { + $serviceTotal += $effectiveInsurance->price; + } + + // Transportation services + if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { + if (false === $onlyInsuranceCalculationServices || true === $participant->transportationOutbound->includeInInsuranceCalculation) { + $serviceTotal += $participant->transportationOutbound->price; + } + } + + if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { + if (false === $onlyInsuranceCalculationServices || true === $participant->transportationInbound->includeInInsuranceCalculation) { + $serviceTotal += $participant->transportationInbound->price; + } + } + + if (null !== $participant->pickup && null !== $participant->pickup->price) { + $serviceTotal += $participant->pickup->price; + } + + if (null !== $participant->parkingService && null !== $participant->parkingService->price) { + if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) { + $serviceTotal += $participant->parkingService->price; + } + } + + // Multiple service selections + $multipleServiceArrays = [ + 'courses' => $participant->courses, + 'additionalServices' => $participant->additionalServices, + 'board' => $participant->board, + 'rentals' => $participant->rentals, + ]; + + foreach ($multipleServiceArrays as $serviceArray) { + if (true === is_array($serviceArray)) { + foreach ($serviceArray as $service) { + if ($service instanceof Service && null !== $service->price) { + if (false === $onlyInsuranceCalculationServices || true === $service->includeInInsuranceCalculation) { + $serviceTotal += $service->price; + } + } + } + } + } + + return $serviceTotal; + } +} diff --git a/src/Service/RoomAssignmentService.php b/src/Service/RoomAssignmentService.php index 281a7f3..8b8bad2 100644 --- a/src/Service/RoomAssignmentService.php +++ b/src/Service/RoomAssignmentService.php @@ -86,4 +86,28 @@ class RoomAssignmentService } } } + + /** + * Assigns rooms to participants if any participants need room assignment. + * + * Checks if any participants have null room assignments, and if so, + * triggers the auto-assignment process. This is a convenience method + * that combines the check and assignment into a single call. + * + * @param BookingDto $dto The booking DTO to update + * + * @return bool True if assignment was performed, false if not needed + */ + public function assignRoomsIfNeeded(BookingDto $dto): bool + { + foreach ($dto->participants as $participant) { + if (null === $participant->assignedRoomId) { + $this->assignParticipantsToRooms($dto); + + return true; + } + } + + return false; + } } diff --git a/src/Service/RoomPricingCalculator.php b/src/Service/RoomPricingCalculator.php new file mode 100644 index 0000000..5d09dab --- /dev/null +++ b/src/Service/RoomPricingCalculator.php @@ -0,0 +1,159 @@ +getMode() && null !== $bookingDto->booking) { + return $this->calculateRoomPricingFromBooking($bookingDto); + } + + // In create mode, use room selections from the form + return $this->calculateRoomPricingFromSelections($bookingDto); + } + + /** + * Calculates total price for all rooms. + */ + public function calculateRoomTotal(BookingDto $bookingDto): float + { + $roomPricing = $this->calculateRoomPricing($bookingDto); + + return array_sum(array_column($roomPricing, 'totalPrice')); + } + + /** + * Retrieves a room by ID from the booking's travel data. + */ + public function getRoomById(BookingDto $bookingDto, ?int $roomId): ?Room + { + if (null === $roomId) { + return null; + } + + foreach ($bookingDto->travel->rooms as $room) { + if ($room->id === $roomId) { + return $room; + } + } + + return null; + } + + /** + * Calculates room pricing from form selections (create mode). + * + * @param BookingDto $bookingDto The booking data containing room selections + * + * @return array Array of room pricing data + */ + private function calculateRoomPricingFromSelections(BookingDto $bookingDto): array + { + $roomPricing = []; + $selectedRooms = $bookingDto->getSelectedRooms(); + + if (true === empty($selectedRooms)) { + return $roomPricing; + } + + foreach ($selectedRooms as $roomSelection) { + $room = $this->getRoomById($bookingDto, $roomSelection->roomId); + if (null === $room || null === $room->price) { + continue; + } + + // Calculate participant count for this room selection + $participantCount = $room->minPax * $roomSelection->quantity; + + // Each participant pays the full room price + $totalPrice = $participantCount * $room->price; + + $roomPricing[] = [ + 'roomId' => $room->id, + 'label' => $room->label, + 'quantity' => $roomSelection->quantity, + 'participantCount' => $participantCount, + 'unitPrice' => $room->price, + 'totalPrice' => $totalPrice, + ]; + } + + return $roomPricing; + } + + /** + * Calculates room pricing from booking entity data (edit mode). + * + * In edit mode, room prices come from the booking entity's individualPrice arrays. + * Each participant has their room price stored in the room's individualPrice array. + * + * @param BookingDto $bookingDto The booking data with booking entity + * + * @return array Array of room pricing data with labels, quantities, and totals + */ + private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array + { + $roomPricing = []; + $roomGroups = []; + + // Group participants by room and sum their individual prices + foreach ($bookingDto->booking->rooms as $room) { + if (false === isset($roomGroups[$room->id])) { + $roomGroups[$room->id] = [ + 'room' => $room, + 'participantCount' => 0, + 'totalPrice' => 0.0, + ]; + } + + // Sum individual prices for all participants in this room + foreach ($room->mapping as $participantIndex) { + $individualPrice = $room->individualPrice[$participantIndex] ?? 0.0; + $roomGroups[$room->id]['totalPrice'] += $individualPrice; + ++$roomGroups[$room->id]['participantCount']; + } + } + + // Build pricing array + foreach ($roomGroups as $roomId => $data) { + $room = $data['room']; + $participantCount = $data['participantCount']; + $totalPrice = $data['totalPrice']; + + // Calculate average unit price (price per person) + $unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; + + $roomPricing[] = [ + 'roomId' => $room->id, + 'label' => $room->label, + 'quantity' => $room->totalCount, + 'participantCount' => $participantCount, + 'unitPrice' => $unitPrice, + 'totalPrice' => $totalPrice, + ]; + } + + return $roomPricing; + } +} diff --git a/src/Service/ServicePricingCalculator.php b/src/Service/ServicePricingCalculator.php new file mode 100644 index 0000000..eed8e89 --- /dev/null +++ b/src/Service/ServicePricingCalculator.php @@ -0,0 +1,432 @@ +getParticipants(); + + if (true === empty($participants)) { + return []; + } + + // Aggregate service selections across all eligible participants + $serviceAggregation = []; + + foreach ($participants as $participantIndex => $participant) { + // Skip ineligible participants (no skipasses available for their age) + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + + $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); + } + + // Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz) + $transportationItems = $this->aggregateTransportationServices($bookingDto); + foreach ($transportationItems as $key => $transportationItem) { + $serviceAggregation[$key] = $transportationItem; + } + + // Group services by subtype and convert to pricing format + return $this->groupServicesBySubtype($serviceAggregation); + } + + /** + * Calculates total price for all services. + */ + public function calculateServiceTotal(BookingDto $bookingDto): float + { + $servicePricing = $this->calculateServicePricing($bookingDto); + + return array_sum(array_column($servicePricing, 'groupTotal')); + } + + /** + * Aggregates all transportation-related services and pricing into separate line items. + * + * Creates separate entries for: + * - Befoerderung: Sum of all positive pickup prices and base transportation costs + * - Befoerderung - Rabatt: Sum of all negative transportation prices (discounts) + * - Parkplatz: Sum of all parking service prices + * + * Only includes transportation costs from eligible participants. + * + * @param BookingDto $bookingDto The booking data containing participants + * + * @return array Array of transportation line items (Befoerderung, Rabatt, Parkplatz) + */ + private function aggregateTransportationServices(BookingDto $bookingDto): array + { + $participants = $bookingDto->getParticipants(); + + $transportationPositiveTotal = 0.0; // Positive transportation/pickup costs + $transportationDiscountTotal = 0.0; // Negative transportation prices (discounts) + $parkingTotal = 0.0; // Parking service costs + + $transportationParticipants = 0; + $discountParticipants = 0; + $parkingParticipants = 0; + + foreach ($participants as $participantIndex => $participant) { + // Skip ineligible participants (no skipasses available for their age) + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + + $participantTransportationPositiveCost = 0.0; + $participantTransportationDiscountCost = 0.0; + $participantParkingCost = 0.0; + + // Transportation service pricing (outbound) + if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { + if ($participant->transportationOutbound->price < 0) { + $participantTransportationDiscountCost += $participant->transportationOutbound->price; + } else { + $participantTransportationPositiveCost += $participant->transportationOutbound->price; + } + } + + // Transportation service pricing (inbound) + if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { + if ($participant->transportationInbound->price < 0) { + $participantTransportationDiscountCost += $participant->transportationInbound->price; + } else { + $participantTransportationPositiveCost += $participant->transportationInbound->price; + } + } + + // Pickup pricing (unified for both directions) + if (null !== $participant->pickup && null !== $participant->pickup->price) { + if ($participant->pickup->price < 0) { + $participantTransportationDiscountCost += $participant->pickup->price; + } else { + $participantTransportationPositiveCost += $participant->pickup->price; + } + } + + // Parking service pricing + if (null !== $participant->parkingService && null !== $participant->parkingService->price) { + $participantParkingCost += $participant->parkingService->price; + } + + // Aggregate participant totals + if ($participantTransportationPositiveCost > 0) { + $transportationPositiveTotal += $participantTransportationPositiveCost; + ++$transportationParticipants; + } + if ($participantTransportationDiscountCost < 0) { + $transportationDiscountTotal += $participantTransportationDiscountCost; + ++$discountParticipants; + } + if ($participantParkingCost > 0) { + $parkingTotal += $participantParkingCost; + ++$parkingParticipants; + } + } + + $transportationItems = []; + + // Add transportation entry (only positive costs) + if ($transportationPositiveTotal > 0) { + $transportationItems['transportation_positive'] = [ + 'serviceId' => 'transportation_positive', + 'label' => 'Beförderung', + 'unitPrice' => null, + 'participantCount' => $transportationParticipants, + 'totalPrice' => $transportationPositiveTotal, + 'subType' => Constants::GROUP_TRANSPORTATION, + ]; + } + + // Add discount entry (only negative costs) + if ($transportationDiscountTotal < 0) { + $transportationItems['transportation_discount'] = [ + 'serviceId' => 'transportation_discount', + 'label' => 'Beförderung - Rabatt', + 'unitPrice' => null, + 'participantCount' => $discountParticipants, + 'totalPrice' => $transportationDiscountTotal, + 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', + ]; + } + + // Add parking entry (only if positive costs) + if ($parkingTotal > 0) { + $transportationItems['transportation_parking'] = [ + 'serviceId' => 'transportation_parking', + 'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], + 'unitPrice' => null, + 'participantCount' => $parkingParticipants, + 'totalPrice' => $parkingTotal, + 'subType' => Constants::GROUP_TRANSPORTATION, + ]; + } + + return $transportationItems; + } + + /** + * Groups services by their subtypes for display, separating positive costs and discounts. + * + * Creates separate entries for positive costs and negative costs (discounts) within each service group. + * For example: "Kurse" and "Kurse - Rabatt" if there are both positive and negative priced course services. + * + * @param array $serviceAggregation Aggregated service data + * + * @return array Grouped services by subtype with separate discount entries + */ + private function groupServicesBySubtype(array $serviceAggregation): array + { + $groupedServices = []; + + // First pass: separate positive and negative prices by subtype + $servicesBySubtypeAndSign = []; + + foreach ($serviceAggregation as $serviceData) { + if (0.0 === $serviceData['totalPrice']) { + continue; // Skip zero-price services + } + + $subType = $serviceData['subType'] ?? 'other'; + + // Normalize rental subtypes to avoid duplicate sections + if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { + $subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key + } + + // Normalize insurance subtypes to avoid duplicate sections + if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { + $subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key + } + + $isDiscount = $serviceData['totalPrice'] < 0; + + // Create separate buckets for positive costs and discounts + $bucketKey = $subType.($isDiscount ? '_discount' : '_regular'); + + if (false === isset($servicesBySubtypeAndSign[$bucketKey])) { + $servicesBySubtypeAndSign[$bucketKey] = [ + 'subType' => $subType, + 'isDiscount' => $isDiscount, + 'services' => [], + 'total' => 0.0, + 'participantCount' => 0, + ]; + } + + $servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData; + $servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice']; + $servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount']; + } + + // Second pass: create display groups + foreach ($servicesBySubtypeAndSign as $bucketData) { + $baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']); + $groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName; + + $groupedServices[] = [ + 'groupName' => $groupName, + 'services' => $bucketData['services'], + 'groupTotal' => $bucketData['total'], + ]; + } + + return $groupedServices; + } + + /** + * Maps service subtypes to user-friendly group names. + */ + private function getGroupNameForSubtype(string $subType): string + { + // Handle rentals array (keep for backward compatibility with non-normalized subtypes) + if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { + return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS]; + } + + return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen'; + } + + /** + * Aggregates service selections from a single participant into the service aggregation array. + */ + private function aggregateParticipantServices( + ParticipantDto $participant, + array &$serviceAggregation, + BookingDto $bookingDto, + ): void { + // Handle single service selections (skiPass, rentalInsurance) + if (null !== $participant->skiPass && null !== $participant->skiPass->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1); + } + + if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); + } + + // Resolve insurance: use price-tier-adjusted insurance for bulk assignment + $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); + + if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) { + $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); + } + + // Handle multiple service selections + $multipleServiceArrays = [ + 'courses' => $participant->courses, + 'additionalServices' => $participant->additionalServices, + 'board' => $participant->board, + 'rentals' => $participant->rentals, + ]; + + foreach ($multipleServiceArrays as $serviceArray) { + if (true === is_array($serviceArray)) { + foreach ($serviceArray as $service) { + if ($service instanceof Service && null !== $service->price) { + $this->addToServiceAggregation($serviceAggregation, $service, 1); + } + } + } + } + } + + /** + * Adds a service to the aggregation array, incrementing count and updating total price. + */ + private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): void + { + $serviceKey = $service->id.'_'.$service->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $service->id, + 'label' => $service->label, + 'unitPrice' => $service->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $service->subType, + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity; + } + + /** + * Adds an insurance to the service aggregation array. + * + * @param array $serviceAggregation The service aggregation array to update + * @param Insurance $insurance The insurance to add + * @param int $quantity The quantity of the insurance + */ + private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void + { + $serviceKey = $insurance->id.'_'.$insurance->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $insurance->id, + 'label' => $insurance->label, + 'unitPrice' => $insurance->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $insurance->getSubType(), + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; + } + + /** + * Resolves the insurance to use for aggregation, handling bulk insurance with price tiers. + * + * When bulk insurance is active, dependent participants get price-tier-adjusted insurance + * based on their individual travel price, matching the logic used in API submission. + * + * @param ParticipantDto $participant The participant to resolve insurance for + * @param BookingDto $bookingDto The booking context + * + * @return Insurance|null The insurance to aggregate (null if none) + */ + private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance + { + // If participant already has insurance assigned, use it + if (null !== $participant->insurance) { + return $participant->insurance; + } + + // Check if bulk insurance is active + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { + return null; // No bulk insurance active + } + + // Applicant always uses their own insurance + if (0 === $participant->index) { + return $participant->insurance; + } + + // For dependent participants: calculate price-tier-adjusted insurance + // This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive() + // Get selectable (non-complementary) insurances with caching + $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); + + // Get insurances of the same type as applicant's selection + $sameTypeInsurances = $this->insuranceService->filterByType( + $selectableInsurances, + $applicant->insurance + ); + + // Calculate travel price for eligibility checks + $travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( + $bookingDto, + $participant->index + ); + + // Get eligible insurances for THIS participant (price tier adjusted) + $eligibleInsurances = $this->insuranceService->getEligibleInsurances( + $sameTypeInsurances, + $participant, + $bookingDto, + $travelPrice + ); + + // Return first eligible insurance (sorted by price) + return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; + } +} diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index e16055b..568a9e9 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -606,6 +606,25 @@ class TravelDataService ]); } + /** + * Enriches travel data with fresh availability information from the API. + * + * Fetches cached availability data and patches it onto the travel object. + * Used by controllers to ensure availability data is up-to-date before + * rendering forms or processing submissions. + * + * @param Travel $travel The travel object to enrich + * @param bool $cached Whether to use cached availability data (default: true) + */ + public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void + { + $availabilities = $this->getAvailabilityData($travel->id, $cached); + + if (null !== $availabilities) { + $this->patchAvailabilities($travel, $availabilities); + } + } + /** * Enrich travel data with additional information. * diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 6116f06..4530f80 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -407,11 +407,8 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': summaryData.participantCount, - 'groupedSelectedRooms': summaryData.groupedSelectedRooms, - 'assignmentCounts': summaryData.assignmentCounts, - 'pricingData': pricingData, - 'cmsData': cmsData + 'summaryData': summaryData, + 'mutableData': mutableData|default(null) } %}
{% endblock %} diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index 1fe9ec8..2315e9e 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -52,34 +52,34 @@ {# Travel Information #}
- {% if cmsData.hotel.images is defined %} - {{ cmsData.hotel.images.resized.s[0].alt }} + {% if summaryData.cmsData.hotel.images is defined %} + {{ summaryData.cmsData.hotel.images.resized.s[0].alt }} {% endif %}
Reise: {{ bookingCreateDto.travel.label }}
- {% if cmsData.region is defined %} -
Gebiet: {{ cmsData.region.name }}
+ {% if summaryData.cmsData.region is defined %} +
Gebiet: {{ summaryData.cmsData.region.name }}
{% endif %} - {% if cmsData.country is defined %} -
Land: {{ cmsData.country.name }}
+ {% if summaryData.cmsData.country is defined %} +
Land: {{ summaryData.cmsData.country.name }}
{% endif %}
Zeitraum: {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}
-
Unterkunft: {{ bookingCreateDto.travel.hotel.name }}{% if cmsData.hotel.address is defined %}, {{ cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}
-
Anzahl Teilnehmer: {{ participantCount }}
+
Unterkunft: {{ bookingCreateDto.travel.hotel.name }}{% if summaryData.cmsData.hotel.address is defined %}, {{ summaryData.cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}
+
Anzahl Teilnehmer: {{ summaryData.participantCount }}
{# Rooms Section #} - {% if pricingData.rooms is not empty %} + {% if summaryData.pricingData.rooms is not empty %}

Unterkunft

- {% for roomPricing in pricingData.rooms %} + {% for roomPricing in summaryData.pricingData.rooms %}
{{ roomPricing.quantity }}x {{ roomPricing.label }} - {% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %} - {{ assignmentCounts[roomPricing.roomId] }} belegt + {% if summaryData.assignmentCounts[roomPricing.roomId] is defined %} + {{ summaryData.assignmentCounts[roomPricing.roomId] }} belegt {% endif %}
@@ -97,10 +97,10 @@ {% endif %} {# Services Section #} - {% if pricingData.services is not empty %} + {% if summaryData.pricingData.services is not empty %}

Leistungen

- {% for serviceGroup in pricingData.services %} + {% for serviceGroup in summaryData.pricingData.services %}
{{ serviceGroup.groupName }}
@@ -121,11 +121,11 @@ {% endif %} {# Surcharges Section (Edit mode only) #} - {% if pricingData.surcharges is defined and pricingData.surcharges is not empty %} + {% if summaryData.pricingData.surcharges is defined and summaryData.pricingData.surcharges is not empty %}

Zuschläge

- {% for surchargePricing in pricingData.surcharges %} + {% for surchargePricing in summaryData.pricingData.surcharges %}
{{ surchargePricing.participantCount }}x {{ surchargePricing.label }} @@ -140,7 +140,7 @@ {% endif %} {# Total Section #} - {% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %} + {% if summaryData.pricingData.grandTotal is defined and summaryData.pricingData.grandTotal > 0 %}
{# Show subtotal and voucher discounts when vouchers are applied #} {% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %} @@ -149,7 +149,7 @@
Gesamtpreis: - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + €{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
@@ -177,14 +177,14 @@
Zu zahlen: - €{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }} + €{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
{% else %}
Gesamtpreis: - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + €{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
{% endif %} diff --git a/templates/booking/create/step_1.html.twig b/templates/booking/create/step_1.html.twig index 83f5c9c..4757848 100644 --- a/templates/booking/create/step_1.html.twig +++ b/templates/booking/create/step_1.html.twig @@ -54,11 +54,7 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingCreateDto, - 'participantCount': participantCount, - 'pricingData': pricingData, - 'cmsData': cmsData, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': [] + 'summaryData': summaryData } %}
{% endblock %} diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index d70a46f..0a7020b 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -64,11 +64,7 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': summaryData.participantCount, - 'groupedSelectedRooms': summaryData.groupedSelectedRooms, - 'assignmentCounts': summaryData.assignmentCounts, - 'pricingData': pricingData, - 'cmsData': cmsData + 'summaryData': summaryData } %}
{% endblock %} diff --git a/templates/booking/create/step_2_participant.html.twig b/templates/booking/create/step_2_participant.html.twig index f07a35d..d9430da 100644 --- a/templates/booking/create/step_2_participant.html.twig +++ b/templates/booking/create/step_2_participant.html.twig @@ -14,11 +14,7 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': summaryData.participantCount, - 'groupedSelectedRooms': summaryData.groupedSelectedRooms, - 'assignmentCounts': summaryData.assignmentCounts, - 'pricingData': pricingData, - 'cmsData': cmsData + 'summaryData': summaryData } %}
diff --git a/templates/booking/create/step_3.html.twig b/templates/booking/create/step_3.html.twig index b7cf7ec..55978dd 100644 --- a/templates/booking/create/step_3.html.twig +++ b/templates/booking/create/step_3.html.twig @@ -59,11 +59,7 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingCreateDto, - 'participantCount': participantCount, - 'pricingData': pricingData, - 'cmsData': cmsData, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts + 'summaryData': summaryData } %}
diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index f991119..35d1dd4 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -30,16 +30,16 @@

Preisübersicht

{# Rooms Section #} - {% if pricingData.rooms is not empty %} + {% if summaryData.pricingData.rooms is not empty %}

Unterkunft

- {% for roomPricing in pricingData.rooms %} + {% for roomPricing in summaryData.pricingData.rooms %}
{{ roomPricing.quantity }}x {{ roomPricing.label }} - {% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %} - {{ assignmentCounts[roomPricing.roomId] }} Person(en) belegt + {% if summaryData.assignmentCounts[roomPricing.roomId] is defined %} + {{ summaryData.assignmentCounts[roomPricing.roomId] }} Person(en) belegt {% endif %}
@@ -57,10 +57,10 @@ {% endif %} {# Services Section #} - {% if pricingData.services is not empty %} + {% if summaryData.pricingData.services is not empty %}

Leistungen

- {% for serviceGroup in pricingData.services %} + {% for serviceGroup in summaryData.pricingData.services %}
{{ serviceGroup.groupName }}
@@ -84,7 +84,7 @@ {% endif %} {# Grand Total #} - {% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %} + {% if summaryData.pricingData.grandTotal is defined and summaryData.pricingData.grandTotal > 0 %}
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %} {% if acceptedVouchers and acceptedVouchers.hasVouchers() %} @@ -92,7 +92,7 @@
Gesamtpreis: - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + €{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
@@ -120,14 +120,14 @@
Zu zahlen: - €{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }} + €{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
{% else %}
Gesamtpreis: - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + €{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
{% endif %} @@ -137,7 +137,7 @@ {# Participants Summary #}
-

Teilnehmer ({{ participantCount }})

+

Teilnehmer ({{ summaryData.participantCount }})

{% for participant in bookingCreateDto.participants %}
diff --git a/templates/booking/edit/index.html.twig b/templates/booking/edit/index.html.twig index 2784c16..bba28d9 100644 --- a/templates/booking/edit/index.html.twig +++ b/templates/booking/edit/index.html.twig @@ -100,11 +100,7 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': participantsCount, - 'pricingData': pricingData, - 'cmsData': cmsData, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts, + 'summaryData': summaryData, 'mutableData': mutableData|default(null) } %}
diff --git a/templates/booking/edit/participant.html.twig b/templates/booking/edit/participant.html.twig index 3083dae..0c61b84 100644 --- a/templates/booking/edit/participant.html.twig +++ b/templates/booking/edit/participant.html.twig @@ -14,11 +14,8 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': summaryData.participantCount, - 'groupedSelectedRooms': summaryData.groupedSelectedRooms, - 'assignmentCounts': summaryData.assignmentCounts, - 'pricingData': pricingData, - 'cmsData': cmsData + 'summaryData': summaryData, + 'mutableData': mutableData|default(null) } %}
diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index cb915c6..e90433d 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -5,6 +5,10 @@ declare(strict_types=1); namespace App\Tests\BusProNet\DataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor; +use App\BusProNet\DataProcessor\BookingPayloadBuilder; +use App\BusProNet\DataProcessor\ParticipantServiceProcessor; +use App\BusProNet\DataProcessor\PersonalDataSynchronizer; +use App\BusProNet\DataProcessor\ServiceMappingCollector; use App\BusProNet\Model\Address; use App\BusProNet\Model\BankAccount; use App\BusProNet\Model\Booking; @@ -44,7 +48,20 @@ class BookingDataProcessorTest extends TestCase $priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance') ->willReturn(500.0); - $this->processor = new BookingDataProcessor($insuranceService, $priceCalculatorService); + // Create the new dependencies + $mappingCollector = new ServiceMappingCollector(); + $serviceProcessor = new ParticipantServiceProcessor(); + $payloadBuilder = new BookingPayloadBuilder($mappingCollector); + $personalDataSynchronizer = new PersonalDataSynchronizer(); + + $this->processor = new BookingDataProcessor( + $insuranceService, + $priceCalculatorService, + $mappingCollector, + $serviceProcessor, + $payloadBuilder, + $personalDataSynchronizer, + ); } public function testCreateUpdateRequestPayloadWithCompleteData(): void diff --git a/tests/Service/BookingPriceCalculatorServiceTest.php b/tests/Service/BookingPriceCalculatorServiceTest.php index 14b05f7..313eb22 100644 --- a/tests/Service/BookingPriceCalculatorServiceTest.php +++ b/tests/Service/BookingPriceCalculatorServiceTest.php @@ -11,24 +11,41 @@ use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use App\Service\BookingPriceCalculatorService; +use App\Service\InsuranceService; use App\Service\ParticipantEligibilityService; +use App\Service\ParticipantPricingCalculator; +use App\Service\RoomPricingCalculator; +use App\Service\ServicePricingCalculator; use PHPUnit\Framework\TestCase; class BookingPriceCalculatorServiceTest extends TestCase { private BookingPriceCalculatorService $service; + private ParticipantEligibilityService $participantEligibilityService; protected function setUp(): void { - $participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); - $participantEligibilityService->method('isParticipantEligible')->willReturn(true); + $this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); + $this->participantEligibilityService->method('isParticipantEligible')->willReturn(true); - // Create a real InsuranceService (it will use a mocked price calculator internally when needed) - $insuranceService = new \App\Service\InsuranceService($this->service ?? $this->createMock(BookingPriceCalculatorService::class)); + // Build the calculator chain + $roomPricingCalculator = new RoomPricingCalculator(); + $participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator); + + // Create InsuranceService with a mock price calculator (to avoid circular reference in tests) + $mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class); + $insuranceService = new InsuranceService($mockPriceCalculator); + + $servicePricingCalculator = new ServicePricingCalculator( + $this->participantEligibilityService, + $insuranceService, + $participantPricingCalculator + ); $this->service = new BookingPriceCalculatorService( - $participantEligibilityService, - $insuranceService + $roomPricingCalculator, + $servicePricingCalculator, + $participantPricingCalculator ); } @@ -493,13 +510,23 @@ class BookingPriceCalculatorServiceTest extends TestCase $participantEligibilityService->method('isParticipantEligible') ->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible - // Create a real InsuranceService with mocked price calculator + // Build the calculator chain with the custom eligibility service + $roomPricingCalculator = new RoomPricingCalculator(); + $participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator); + $mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class); - $insuranceService = new \App\Service\InsuranceService($mockPriceCalculator); + $insuranceService = new InsuranceService($mockPriceCalculator); + + $servicePricingCalculator = new ServicePricingCalculator( + $participantEligibilityService, + $insuranceService, + $participantPricingCalculator + ); $this->service = new BookingPriceCalculatorService( - $participantEligibilityService, - $insuranceService + $roomPricingCalculator, + $servicePricingCalculator, + $participantPricingCalculator ); $result = $this->service->calculateServicePricing($bookingDto);