feat: refactoring and cleanup

This commit is contained in:
Björn Fromme
2025-12-06 15:02:13 +01:00
parent f4691ec67e
commit 696800aafd
41 changed files with 2591 additions and 2233 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,485 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\Form\Model\BookingDto;
/**
* Builds API payload structures for BusProNet booking requests.
*
* Transforms booking data into the structured array format expected by the
* BusProNet XML API for both create and update operations.
*/
class BookingPayloadBuilder
{
public function __construct(
private readonly ServiceMappingCollector $mappingCollector,
) {
}
/**
* 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
*/
public 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
*/
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,
];
}
}
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\Form\Model\ParticipantDto;
/**
* Processes participant service selections for booking updates.
*
* Handles the assignment and mapping of services (additional, transportation,
* pickup, room, insurance) from participant DTOs to the booking model during
* the update request workflow.
*/
class ParticipantServiceProcessor
{
/**
* 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
*/
public 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
*/
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;
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\Form\Model\ParticipantDto;
/**
* Synchronizes personal data between form DTOs and booking models.
*
* Handles the update of participant personal data, addresses, and communication
* information from form submissions to the booking model during update operations.
*/
class PersonalDataSynchronizer
{
/**
* 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<ParticipantDto> $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;
}
}
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Collects service-to-participant mappings for BusProNet API payloads.
*
* Extracts and groups participant service selections into structured maps
* keyed by service ID with arrays of participant IDs (1-based for API).
*/
class ServiceMappingCollector
{
/**
* Collects room mappings.
*
* Groups participants by their assigned room ID.
*
* @return array<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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 <gutscheine> collection in the booking payload.
*
* IMPORTANT: Goodwill vouchers (Kulanz) are excluded from this collection.
* They are added per participant as <aktionscode> 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 <einloesecode> 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;
}
}
@@ -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,
]
);
}
@@ -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);
}
}
}
@@ -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,
]);
}
@@ -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),
]);
}
+63 -177
View File
@@ -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);
$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) {
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
}
// Subsequent load: refresh from session with staleness check
return $this->refreshFromSession($formData);
}
/**
* 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 $formData;
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
}
}
@@ -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);
@@ -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<array{type: string, message: string}> $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;
}
+9
View File
@@ -33,6 +33,8 @@ class AcceptedVouchersDto
/**
* @return array<AcceptedVoucherDto>
*
* @deprecated No known usages outside tests
*/
public function getPromotionalVouchers(): array
{
@@ -41,6 +43,8 @@ class AcceptedVouchersDto
/**
* @return array<AcceptedVoucherDto>
*
* @deprecated No known usages outside tests
*/
public function getPurchaseVouchers(): array
{
@@ -49,6 +53,8 @@ class AcceptedVouchersDto
/**
* @return array<AcceptedVoucherDto>
*
* @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);
+4
View File
@@ -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
{
+70
View File
@@ -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<int, int> 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<int, array{int, int}> 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<int, array{int, int}> $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;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
/**
* DTO containing all booking summary data for sidebar display.
*
* Provides pricing breakdowns, room assignments, participant counts,
* and CMS product information in a single typed object.
*/
class BookingSummaryDto
{
/**
* @param array<int, RoomSelectionDto> $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<array{room: mixed, count: int}> $groupedSelectedRooms Rooms grouped by participant assignments
* @param array<int, int> $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,
) {
}
}
+9
View File
@@ -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
{
@@ -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;
}
}
@@ -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<int, Pickup> $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);
}
}
@@ -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<int, Service> $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);
}
}
@@ -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<int, Service> $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);
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification;
use App\Form\Model\BookingDto;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Handles loading and initializing booking data for edit mode.
*
* Encapsulates the logic for loading booking data from session or API,
* refreshing availability data, and detecting session staleness.
*/
class BookingEditDataLoaderService
{
private const STALENESS_THRESHOLD_SECONDS = 300; // 5 minutes
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly BookingService $bookingService,
private readonly BookingFingerprintService $fingerprintService,
private readonly TravelDataService $travelDataService,
private readonly CacheInterface $cache,
) {
}
/**
* Loads booking data from session or initializes from API on first load.
*
* @return array{bookingDto: BookingDto|null, stalenessWarning: string|null}
*/
public function loadFormData(Request $request, int $bookingId, string $email, string $password): array
{
$formData = $this->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);
}
}
+20 -701
View File
@@ -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<string, float> 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;
}
}
+59 -52
View File
@@ -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<int, int> 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<int, array{int, int}> 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;
}
}
+12 -21
View File
@@ -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,
);
}
/**
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Calculates pricing for individual participants in a booking.
*
* Handles room allocation costs, service selections, and provides
* both inclusive and exclusive insurance calculations for eligibility.
*/
class ParticipantPricingCalculator
{
/** @var array<string, float> 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;
}
}
+24
View File
@@ -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;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Room;
use App\Form\Model\BookingDto;
/**
* Calculates pricing for room allocations in bookings.
*
* Handles room pricing in both create mode (from room selections) and
* edit mode (from booking entity data with individual participant prices).
*/
class RoomPricingCalculator
{
/**
* Calculates pricing for all selected rooms.
*
* @param BookingDto $bookingDto The booking data containing room selections
*
* @return array Array of room pricing data with labels, quantities, and totals
*/
public function calculateRoomPricing(BookingDto $bookingDto): array
{
// 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
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;
}
}
+432
View File
@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Calculates pricing for services across all participants in a booking.
*
* Handles aggregation of service selections, transportation costs, and
* insurance pricing with bulk insurance support. Groups services by
* subtype for display with separate discount entries.
*/
class ServicePricingCalculator
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService,
private readonly InsuranceService $insuranceService,
private readonly ParticipantPricingCalculator $participantPricingCalculator,
) {
}
/**
* Calculates pricing for all selected services across all participants, grouped by subtype.
*
* Only includes services from eligible participants (those with available skipasses for their age).
*
* @param BookingDto $bookingDto The booking data containing participants and their service selections
*
* @return array Array of service groups with each group containing services of the same subtype
*/
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);
}
/**
* 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;
}
}
+19
View File
@@ -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.
*
@@ -407,11 +407,8 @@
<div id="booking-summary" hx-swap-oob="true">
{% 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)
} %}
</div>
{% endblock %}
+20 -20
View File
@@ -52,34 +52,34 @@
{# Travel Information #}
<div class="mb-6 pb-4 border-b border-gray-200">
{% if cmsData.hotel.images is defined %}
<img src="{{ cmsData.hotel.images.resized.l[0].url }}" alt="{{ cmsData.hotel.images.resized.s[0].alt }}" class="w-full rounded mb-4">
{% if summaryData.cmsData.hotel.images is defined %}
<img src="{{ summaryData.cmsData.hotel.images.resized.l[0].url }}" alt="{{ summaryData.cmsData.hotel.images.resized.s[0].alt }}" class="w-full rounded mb-4">
{% endif %}
<div class="space-y-2 text-sm text-gray-600">
<div><span class="font-medium">Reise:</span> {{ bookingCreateDto.travel.label }}</div>
{% if cmsData.region is defined %}
<div><span class="font-medium">Gebiet:</span> {{ cmsData.region.name }}</div>
{% if summaryData.cmsData.region is defined %}
<div><span class="font-medium">Gebiet:</span> {{ summaryData.cmsData.region.name }}</div>
{% endif %}
{% if cmsData.country is defined %}
<div><span class="font-medium">Land:</span> {{ cmsData.country.name }}</div>
{% if summaryData.cmsData.country is defined %}
<div><span class="font-medium">Land:</span> {{ summaryData.cmsData.country.name }}</div>
{% endif %}
<div><span class="font-medium">Zeitraum:</span> {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}</div>
<div><span class="font-medium">Unterkunft:</span> {{ bookingCreateDto.travel.hotel.name }}{% if cmsData.hotel.address is defined %}, {{ cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}</div>
<div><span class="font-medium">Anzahl Teilnehmer:</span> {{ participantCount }}</div>
<div><span class="font-medium">Unterkunft:</span> {{ bookingCreateDto.travel.hotel.name }}{% if summaryData.cmsData.hotel.address is defined %}, {{ summaryData.cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}</div>
<div><span class="font-medium">Anzahl Teilnehmer:</span> {{ summaryData.participantCount }}</div>
</div>
</div>
{# Rooms Section #}
{% if pricingData.rooms is not empty %}
{% if summaryData.pricingData.rooms is not empty %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Unterkunft</h4>
<div class="space-y-2">
{% for roomPricing in pricingData.rooms %}
{% for roomPricing in summaryData.pricingData.rooms %}
<div class="flex justify-between items-start">
<div class="text-sm text-gray-600">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span>
{% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-500">{{ assignmentCounts[roomPricing.roomId] }} belegt</span>
{% if summaryData.assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-500">{{ summaryData.assignmentCounts[roomPricing.roomId] }} belegt</span>
{% endif %}
</div>
<div class="text-right">
@@ -97,10 +97,10 @@
{% endif %}
{# Services Section #}
{% if pricingData.services is not empty %}
{% if summaryData.pricingData.services is not empty %}
<div class="mb-6">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Leistungen</h4>
{% for serviceGroup in pricingData.services %}
{% for serviceGroup in summaryData.pricingData.services %}
<div class="mb-4 last:mb-0">
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5>
<div class="space-y-1 ml-4">
@@ -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 %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Zuschläge</h4>
<div class="space-y-1">
{% for surchargePricing in pricingData.surcharges %}
{% for surchargePricing in summaryData.pricingData.surcharges %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{{ 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 %}
<div class="pt-4 border-t-2 border-gray-300">
{# Show subtotal and voucher discounts when vouchers are applied #}
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
@@ -149,7 +149,7 @@
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">Gesamtpreis:</span>
<span class="text-gray-900">
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
@@ -177,14 +177,14 @@
<div class="flex justify-between items-center pt-2 border-t border-gray-200">
<span class="font-bold text-lg text-gray-800">Zu zahlen:</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
</span>
</div>
{% else %}
<div class="flex justify-between items-center">
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
{% endif %}
+1 -5
View File
@@ -54,11 +54,7 @@
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'participantCount': participantCount,
'pricingData': pricingData,
'cmsData': cmsData,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': []
'summaryData': summaryData
} %}
</div>
{% endblock %}
+1 -5
View File
@@ -64,11 +64,7 @@
<div id="booking-summary"{% if htmx_oob_swap|default(false) %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'participantCount': summaryData.participantCount,
'groupedSelectedRooms': summaryData.groupedSelectedRooms,
'assignmentCounts': summaryData.assignmentCounts,
'pricingData': pricingData,
'cmsData': cmsData
'summaryData': summaryData
} %}
</div>
{% endblock %}
@@ -14,11 +14,7 @@
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'participantCount': summaryData.participantCount,
'groupedSelectedRooms': summaryData.groupedSelectedRooms,
'assignmentCounts': summaryData.assignmentCounts,
'pricingData': pricingData,
'cmsData': cmsData
'summaryData': summaryData
} %}
</div>
</div>
+1 -5
View File
@@ -59,11 +59,7 @@
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'participantCount': participantCount,
'pricingData': pricingData,
'cmsData': cmsData,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
'summaryData': summaryData
} %}
</div>
</div>
+11 -11
View File
@@ -30,16 +30,16 @@
<h3 class="text-xl font-semibold mb-6 text-blue-900">Preisübersicht</h3>
{# Rooms Section #}
{% if pricingData.rooms is not empty %}
{% if summaryData.pricingData.rooms is not empty %}
<div class="mb-6 pb-6 border-b border-blue-200">
<h4 class="font-semibold text-lg mb-3 text-blue-800">Unterkunft</h4>
<div class="space-y-2">
{% for roomPricing in pricingData.rooms %}
{% for roomPricing in summaryData.pricingData.rooms %}
<div class="flex justify-between items-start">
<div class="text-sm text-gray-700">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span>
{% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-600">{{ assignmentCounts[roomPricing.roomId] }} Person(en) belegt</span>
{% if summaryData.assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-600">{{ summaryData.assignmentCounts[roomPricing.roomId] }} Person(en) belegt</span>
{% endif %}
</div>
<div class="text-right">
@@ -57,10 +57,10 @@
{% endif %}
{# Services Section #}
{% if pricingData.services is not empty %}
{% if summaryData.pricingData.services is not empty %}
<div class="mb-6 pb-6 border-b border-blue-200">
<h4 class="font-semibold text-lg mb-3 text-blue-800">Leistungen</h4>
{% for serviceGroup in pricingData.services %}
{% for serviceGroup in summaryData.pricingData.services %}
<div class="mb-4 last:mb-0">
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5>
<div class="space-y-1.5 ml-4">
@@ -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 %}
<div class="pt-4">
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
{% if acceptedVouchers and acceptedVouchers.hasVouchers() %}
@@ -92,7 +92,7 @@
<div class="flex justify-between items-center mb-3">
<span class="text-lg text-blue-800">Gesamtpreis:</span>
<span class="text-lg text-blue-800">
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
@@ -120,14 +120,14 @@
<div class="flex justify-between items-center">
<span class="font-bold text-xl text-blue-900">Zu zahlen:</span>
<span class="font-bold text-2xl text-blue-900">
{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
</span>
</div>
{% else %}
<div class="flex justify-between items-center">
<span class="font-bold text-xl text-blue-900">Gesamtpreis:</span>
<span class="font-bold text-2xl text-blue-900">
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
{% endif %}
@@ -137,7 +137,7 @@
{# Participants Summary #}
<div class="mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 class="text-xl font-semibold mb-4">Teilnehmer ({{ participantCount }})</h3>
<h3 class="text-xl font-semibold mb-4">Teilnehmer ({{ summaryData.participantCount }})</h3>
{% for participant in bookingCreateDto.participants %}
<div class="mb-6 pb-6 {% if not loop.last %}border-b border-gray-300{% endif %}">
<div class="flex justify-between items-start mb-3">
+1 -5
View File
@@ -100,11 +100,7 @@
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'participantCount': participantsCount,
'pricingData': pricingData,
'cmsData': cmsData,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
</div>
+2 -5
View File
@@ -14,11 +14,8 @@
<div id="booking-summary">
{% 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)
} %}
</div>
</div>
@@ -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
@@ -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);