diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index e167d72..b7f9596 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -69,6 +69,20 @@ class BookingDataProcessor $participantData = ParticipantDto::fromPersonalData($participant); $participantData->index = $index; + // First participant (applicant): copy address from applicant if participant address is empty + // BPN API may return full address only in but minimal/empty address in + // Only copy if first participant has no street (indicating empty/incomplete address) + // This allows applicant and first participant to be different people with different addresses + if (0 === $index && null !== $booking->applicant->address) { + $isEmpty = null === $participantData->address + || null === $participantData->address->street + || '' === trim($participantData->address->street); + + if ($isEmpty) { + $participantData->address = clone $booking->applicant->address; + } + } + // Extract service selections from booking and assign to participant DTO $participantData->courses = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); @@ -429,13 +443,16 @@ class BookingDataProcessor * 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 { - if (null === $participant->insurance) { + // Skip if no insurance or if participant selected "keine Versicherung gewünscht" + if (null === $participant->insurance || $participant->insurance->isNoInsurance()) { return; } @@ -760,13 +777,9 @@ class BookingDataProcessor 'nationalitaet' => $firstParticipant->nationality ?? '', ]; - // Include BPN IDs for linking to existing records (authenticated users) - if (null !== $firstParticipant->addressId) { - $payload['anmelder']['idadresse'] = $firstParticipant->addressId; - } - if (null !== $firstParticipant->personId) { - $payload['anmelder']['idadresseperson'] = $firstParticipant->personId; - } + // 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'); @@ -798,13 +811,9 @@ class BookingDataProcessor 'nationalitaet' => $participant->nationality ?? '', ]; - // Include BPN IDs for linking to existing records (authenticated users) - if (null !== $participant->addressId) { - $participantData['idadresse'] = $participant->addressId; - } - if (null !== $participant->personId) { - $participantData['idadresseperson'] = $participant->personId; - } + // 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'); @@ -1085,6 +1094,7 @@ class BookingDataProcessor * Collects insurance mappings. * * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. + * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. * * @return array> Map of insurance ID to participant IDs */ @@ -1095,7 +1105,8 @@ class BookingDataProcessor foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; - if (null !== $participant->insurance) { + // Exclude synthetic "keine Versicherung gewünscht" option from BPN XML + if (null !== $participant->insurance && false === $participant->insurance->isNoInsurance()) { $insuranceMap[$participant->insurance->id][] = $participantId; } } diff --git a/src/BusProNet/Model/Address.php b/src/BusProNet/Model/Address.php index 5bd7b61..a6a409d 100644 --- a/src/BusProNet/Model/Address.php +++ b/src/BusProNet/Model/Address.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace App\BusProNet\Model; -use Symfony\Component\Validator\Constraints as Assert; - /** * Represents a physical address with street, postal code, city, and country information. * @@ -15,18 +13,14 @@ use Symfony\Component\Validator\Constraints as Assert; */ class Address { - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $street = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $postCode = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $city = null; public ?string $district = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $country = null; /** diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index 2ea57a7..5b0a855 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -16,6 +16,15 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; */ class Insurance { + /** + * Special ID for the synthetic "no insurance" option. + * + * This option is injected into the insurance selection list to force explicit user choice + * for legal compliance. When selected, it satisfies validation requirements but transmits + * no insurance data to the BPN API. + */ + public const NO_INSURANCE_ID = '0'; + #[Groups(['api:single', 'api:list'])] public ?string $id = null; @@ -180,4 +189,17 @@ class Insurance return array_values(array_unique($urls)); } + + /** + * Checks if this is the synthetic "no insurance" option. + * + * The "no insurance" option is a UI construct used to force explicit user choice. + * It should be excluded from BPN API transmission. + * + * @return bool True if this is the "no insurance" option + */ + public function isNoInsurance(): bool + { + return self::NO_INSURANCE_ID === $this->id; + } } diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index 86fd613..340634d 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -106,6 +106,26 @@ class IndexController extends AbstractController return $agency->id; } + /** + * Cancels the active booking session and returns to the login page. + * + * This endpoint allows users to exit the booking flow at any time by + * clearing the booking session data and redirecting them back to the + * regular login screen. + */ + #[Route('/bookings/cancel', name: 'app_booking_cancel')] + public function cancel(Request $request): Response + { + // Clear the booking session + $this->bookingService->clearBookingSession($request); + + // Add a flash message to inform the user + $this->addFlash('info', 'Buchung abgebrochen.'); + + // Redirect to login page + return $this->redirectToRoute('app_login'); + } + /** * Displays user-friendly error messages for booking initialization failures. * diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index b7a6632..8023469 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -322,7 +322,7 @@ class IndexController extends AbstractController } // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityDataCached($bookingDto->travel->id); + $availabilities = $this->travelDataService->getAvailabilityData($bookingDto->travel->id, cached: true); if (null !== $availabilities) { $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); } @@ -505,7 +505,7 @@ class IndexController extends AbstractController private function refreshFromSession(BookingDto $formData): BookingDto { // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); + $availabilities = $this->travelDataService->getAvailabilityData($formData->travel->id, cached: true); if (null !== $availabilities) { $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 146c205..0f034fc 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -11,7 +11,6 @@ use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; #[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])] -#[AppAssert\ApplicantAddress(groups: ['booking_create'])] class ParticipantDto { /** @@ -42,10 +41,8 @@ class ParticipantDto public bool $mutable = false; public bool $touched = false; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?string $firstName = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?string $lastName = null; public ?string $title = null; public ?string $gender = null; @@ -55,10 +52,8 @@ class ParticipantDto public ?string $shoeSize = null; public ?string $weight = null; - #[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?\DateTimeImmutable $dateOfBirth = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])] public ?string $email = null; @@ -76,7 +71,6 @@ class ParticipantDto public array $courses = []; public array $additionalServices = []; - #[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create'])] public ?Service $skiPass = null; public array $board = []; diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 5c3a4d5..4484102 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Form\Model; +use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -15,6 +16,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * the participant being edited and the full booking context needed for * cross-participant validation. */ +#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])] class ParticipantEditDto { public function __construct( diff --git a/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php index 1e1ebc7..cf3ea21 100644 --- a/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php +++ b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php @@ -10,38 +10,44 @@ use App\Form\Service\Contract\FieldConditionInterface; /** * Condition that determines if personal data fields should be hidden for authenticated users. * - * When a participant is linked to an existing BPN account (has personId), - * their personal data should not be editable during the booking process. Changes to - * master personal data should only happen through the dedicated personal data management - * interface to prevent: - * - Creating duplicate customer records in BPN - * - Disconnecting bookings from the user's account - * - Data inconsistencies between booking and account data + * ⚠️ IMPORTANT: This condition is used in CREATE MODE ONLY to protect authenticated + * applicant data during booking creation. It is NOT used in edit mode. * - * Note: In edit mode, the BPN API may not return addressId in booking responses, - * so we rely on personId alone to identify authenticated users. The personId is - * sufficient to link a participant to an existing BPN account. + * In CREATE mode: + * - When a logged-in user creates a booking, their personal data is prepopulated from + * their BPN account (including personId). + * - Their personal data fields should be hidden and displayed as static text to prevent + * modifications that could: + * - Create duplicate customer records in BPN + * - Disconnect bookings from the user's account + * - Cause data inconsistencies between booking and account data + * + * In EDIT mode: + * - DO NOT use this condition. Edit mode uses PersonalDataMutabilityCondition instead, + * which respects the BPN API's per-participant `mutable` flag (aenderungmoeglich). + * - The mutability flag determines editability for ALL participants uniformly in edit mode. * * When this condition is satisfied (returns true), the template should: * - Hide the form fields for personal data - * - Display the values as static, read-only text - * - * This follows the same pattern as bulk insurance booking. + * - Display the values as static, read-only text via the field_or_static macro */ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface { /** - * Evaluates if personal data fields should be hidden (not editable). + * Evaluates if personal data fields should be hidden (not editable) in CREATE mode. * - * Returns true when the participant has both addressId and personId set, - * indicating they are linked to an existing BPN account. When true, - * personal data fields should be hidden and displayed as static text. + * Returns true when the participant has a personId set, indicating they are + * a logged-in user whose data was prepopulated from their BPN account. + * When true, personal data fields should be hidden and displayed as static text. * - * @param BookingDto $bookingDto The current booking data + * This condition is only used during booking creation, not in edit mode. + * Edit mode uses PersonalDataMutabilityCondition to respect BPN's mutability flag. + * + * @param BookingDto $bookingDto The current booking data (create flow) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused) * - * @return bool True if fields should be hidden (participant linked to BPN account) + * @return bool True if fields should be hidden (authenticated user in create mode) */ public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { @@ -50,17 +56,16 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface return false; } - // Hide personal data fields if participant has BPN person ID - // In edit mode, bookings may not include addressId, so we check personId only - // The personId alone is sufficient to identify a participant linked to a BPN account + // Hide personal data fields if participant has BPN person ID (authenticated user in create mode) + // The personId is set during prepopulation when a logged-in user starts creating a booking return null !== $participant->personId; } /** * Returns field names that this condition depends on. * - * This condition is based on addressId/personId which are set during prepopulation - * and don't change during the form interaction, so no field dependencies. + * This condition is based on personId which is set during prepopulation + * and doesn't change during form interaction, so no field dependencies. * * @return string[] Empty array - no field dependencies */ @@ -76,6 +81,6 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface */ public function getDescription(): string { - return 'Personal data is not editable when participant is linked to BPN account (prevents duplicate records)'; + return 'Personal data is not editable for authenticated users in create mode (prevents duplicate records)'; } } diff --git a/src/Form/Service/Condition/PersonalDataMutabilityCondition.php b/src/Form/Service/Condition/PersonalDataMutabilityCondition.php new file mode 100644 index 0000000..19b96eb --- /dev/null +++ b/src/Form/Service/Condition/PersonalDataMutabilityCondition.php @@ -0,0 +1,75 @@ + $formData Current form data (unused) + * + * @return bool True if fields should be hidden (participant data not mutable) + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return false; + } + + // Hide personal data fields if BPN indicates participant is not mutable + return false === $participant->mutable; + } + + /** + * Returns field names that this condition depends on. + * + * This condition is based on the mutable flag which is set from the BPN API + * and doesn't change during form interaction, so no field dependencies. + * + * @return string[] Empty array - no field dependencies + */ + public function getDependentFields(): array + { + return []; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string Description of the mutability check logic + */ + public function getDescription(): string + { + return 'Personal data is not editable when BPN mutability flag is false (edit mode only)'; + } +} diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index a6001a2..b43497b 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -12,7 +12,7 @@ use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; -use App\Form\Service\Condition\MutabilityCondition; +use App\Form\Service\Condition\PersonalDataMutabilityCondition; use App\Form\Service\Condition\PickupsMutabilityCondition; use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; @@ -44,13 +44,21 @@ class EditFieldStateProvider extends AbstractFieldStateProvider $transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition(); $pickupsMutabilityCondition = new PickupsMutabilityCondition(); - // Authenticated user personal data protection - // Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records) + // Personal data protection in edit mode has TWO rules: + // 1. Applicant with personId (authenticated user) - prevents editing master personal data + // Only applies to applicant (index 0), not other participants with personId + // 2. BPN mutability flag (mutable=false) - respects BPN business rules for any participant + // Hide fields if EITHER condition is true + $applicantCondition = new ApplicantCondition(); $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); + $personalDataMutabilityCondition = new PersonalDataMutabilityCondition(); + $personalDataHiddenCondition = CompositeCondition::or( + CompositeCondition::and($applicantCondition, $authenticatedUserCondition), + $personalDataMutabilityCondition + ); - // Make all personal data fields readonly if participant not mutable - // OR static text if participant is linked to BPN account (authenticated user) - // Note: First participant is now treated as independent from applicant and can be edited + // Render all personal data fields as static text if authenticated user OR BPN indicates not mutable + // Using 'static_text' state (not 'hidden') so template renders values as static text $personalDataFields = [ 'firstName', 'lastName', @@ -62,36 +70,30 @@ class EditFieldStateProvider extends AbstractFieldStateProvider ]; foreach ($personalDataFields as $field) { $this->fieldStateConditions[$field] = [ - 'static_text' => $authenticatedUserCondition, - 'readonly' => CompositeCondition::not(new MutabilityCondition()), + 'static_text' => $personalDataHiddenCondition, ]; } - // Address fields - static text for authenticated users, readonly if participant not mutable + // Address fields - render as static text if authenticated user OR participant not mutable $this->fieldStateConditions['address'] = [ - 'static_text' => $authenticatedUserCondition, - 'readonly' => CompositeCondition::not(new MutabilityCondition()), + 'static_text' => $personalDataHiddenCondition, ]; - // Address subfields - must render as static text to prevent creating duplicate BPN records + // Address subfields - render as static text if authenticated user OR participant not mutable $this->fieldStateConditions['address.street'] = [ - 'static_text' => $authenticatedUserCondition, + 'static_text' => $personalDataHiddenCondition, ]; $this->fieldStateConditions['address.postCode'] = [ - 'static_text' => $authenticatedUserCondition, + 'static_text' => $personalDataHiddenCondition, ]; $this->fieldStateConditions['address.city'] = [ - 'static_text' => $authenticatedUserCondition, + 'static_text' => $personalDataHiddenCondition, ]; $this->fieldStateConditions['address.country'] = [ - 'static_text' => $authenticatedUserCondition, - ]; - - $this->fieldStateConditions['address.district'] = [ - 'static_text' => $authenticatedUserCondition, + 'static_text' => $personalDataHiddenCondition, ]; // Conditional visibility for service fields (same as create flow) diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 84ad872..f5331e9 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -490,6 +490,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => false, 'expanded' => true, 'required' => false, + 'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead 'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex), 'choice_value' => 'id', 'choice_label' => function (?Insurance $insurance) { @@ -501,7 +502,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $label; }, - 'placeholder' => 'Keine Versicherung gewünscht', ]; // Future field providers would be added here, for example: @@ -799,6 +799,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider /** * Gets eligible insurances for a participant based on eligibility criteria. * + * Injects a synthetic "keine Versicherung gewünscht" option at the top of the list + * to force explicit user choice for legal compliance. + * * @param BookingDto $bookingDto The booking DTO containing travel and participant data * @param int $participantIndex The index of the participant to get eligible insurances for * @@ -818,12 +821,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); // Filter based on eligibility criteria for this participant - return $this->insuranceService->getEligibleInsurances( + $eligibleInsurances = $this->insuranceService->getEligibleInsurances( $selectableInsurances, $participant, $bookingDto, $travelPrice ); + + // Inject synthetic "no insurance" option at the top of the list + // This forces explicit user choice for legal compliance + $noInsurance = new Insurance(); + $noInsurance->id = Insurance::NO_INSURANCE_ID; + $noInsurance->label = 'keine Versicherung gewünscht'; + $noInsurance->price = 0.0; + + // Prepend to list (appears first in radio buttons) + array_unshift($eligibleInsurances, $noInsurance); + + return $eligibleInsurances; } /** diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index bef3489..c92c99f 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -145,6 +145,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler // Handle explicit new insurance selection from user if ($isNewSelection) { + // Handle synthetic "no insurance" option + if (Insurance::NO_INSURANCE_ID === $selectedInsuranceId) { + // Create synthetic insurance object to satisfy validation + // This will be excluded from BPN XML transmission + $noInsurance = new Insurance(); + $noInsurance->id = Insurance::NO_INSURANCE_ID; + $noInsurance->label = 'keine Versicherung gewünscht'; + $noInsurance->price = 0.0; + $participant->insurance = $noInsurance; + + return; // Skip all other processing for "no insurance" + } + $selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId); if (null !== $selectedInsurance) { diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index b49afaa..a81f95c 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -586,21 +586,6 @@ class TravelDataService } } - /** - * Fetch availability data with short-term caching. - * - * @deprecated Use getAvailabilityData($dateId, cached: true) instead - * - * @param int $dateId The travel date ID for API call - * @param int $ttl Cache TTL in seconds (default: 60 seconds) - * - * @return BaseData|null The availability data or null if not available or error occurred - */ - public function getAvailabilityDataCached(int $dateId, int $ttl = 60): ?BaseData - { - return $this->getAvailabilityData($dateId, cached: true, ttl: $ttl); - } - /** * Apply availability data to travel services. * diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 5a2ad3a..e102964 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -69,6 +69,7 @@ class AppRuntime implements RuntimeExtensionInterface 'S' => 'Stornierung', 'O' => 'Option', 'U' => 'Umbuchung', + 'A' => 'Anfrage', default => '', }; } diff --git a/src/Validator/Constraints/ApplicantAddress.php b/src/Validator/Constraints/ApplicantAddress.php deleted file mode 100644 index b4de799..0000000 --- a/src/Validator/Constraints/ApplicantAddress.php +++ /dev/null @@ -1,16 +0,0 @@ -isApplicant()) { - return; - } - - // Ensure address object exists - if (null === $participant->address) { - return; - } - - // Validate required address fields - if (null === $participant->address->street || '' === trim($participant->address->street)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.street') - ->addViolation() - ; - } - - if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.postCode') - ->addViolation() - ; - } - - if (null === $participant->address->city || '' === trim($participant->address->city)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.city') - ->addViolation() - ; - } - - if (null === $participant->address->country || '' === trim($participant->address->country)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.country') - ->addViolation() - ; - } - - // Mobile/phone is mandatory for applicant in create mode - if (null === $participant->mobile || '' === trim($participant->mobile)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('mobile') - ->addViolation() - ; - } - } -} diff --git a/src/Validator/Constraints/Booking.php b/src/Validator/Constraints/Booking.php index df034cf..60a0155 100644 --- a/src/Validator/Constraints/Booking.php +++ b/src/Validator/Constraints/Booking.php @@ -1,14 +1,24 @@ apiClient->updateBooking($bookingData); - - if ($result instanceof Notification) { - $this->context - ->buildViolation($result->message) - ->addViolation() - ; + if (!$value instanceof ParticipantEditDto) { + return; } - if ($result instanceof BookingUpdate && false === $result->valid) { - $this->context - ->buildViolation($constraint->message) - ->addViolation() - ; + $participant = $value->participant; + $bookingContext = $value->bookingContext; + + // Determine if strict validation applies + if (true === $this->shouldApplyStrictValidation($bookingContext)) { + $this->enforceStrictValidation($participant); + } + + // Relaxed validation: no required checks, format validation handled by existing constraints + } + + /** + * Determines whether strict validation should be applied. + * + * Strict validation applies when: + * 1. In create mode (new booking), OR + * 2. In edit mode AND applicant is not mutable (booking has restrictions) + * + * @param BookingDto $bookingContext The booking context + * + * @return bool True if strict validation should apply + */ + private function shouldApplyStrictValidation(BookingDto $bookingContext): bool + { + $mode = $bookingContext->getMode(); + + // Create mode: always strict + if (BookingDto::MODE_CREATE === $mode) { + return true; + } + + // Edit mode: check applicant mutability + $applicant = $bookingContext->participants[0] ?? null; + if (null === $applicant) { + return true; // Fail-safe: if no applicant, apply strict validation + } + + // Strict validation if applicant is not mutable + return false === $applicant->mutable; + } + + /** + * Enforces strict validation: all required fields must be filled. + * + * Validates: + * - Personal data: firstName, lastName, dateOfBirth, email + * - Address: street, postCode, city, country + * - Services: skiPass, transportationOutbound, transportationInbound, insurance + * + * @param ParticipantDto $participant The participant to validate + */ + private function enforceStrictValidation(ParticipantDto $participant): void + { + // Skip validation for canceled participants + if (true === $participant->isCanceled()) { + return; + } + + // Personal data validation + $this->validateRequired($participant->firstName, 'participant.firstName', 'Bitte angeben'); + $this->validateRequired($participant->lastName, 'participant.lastName', 'Bitte angeben'); + $this->validateRequired($participant->email, 'participant.email', 'Bitte angeben'); + + if (null === $participant->dateOfBirth) { + $this->context->buildViolation('Bitte angeben') + ->atPath('participant.dateOfBirth') + ->addViolation(); + } + + // Address validation only for applicant (index 0) + if (0 === $participant->index) { + if (null !== $participant->address) { + $this->validateRequired($participant->address->street, 'participant.address.street', 'Bitte angeben'); + $this->validateRequired($participant->address->postCode, 'participant.address.postCode', 'Bitte angeben'); + $this->validateRequired($participant->address->city, 'participant.address.city', 'Bitte angeben'); + $this->validateRequired($participant->address->country, 'participant.address.country', 'Bitte angeben'); + } else { + $this->context->buildViolation('Bitte angeben') + ->atPath('participant.address') + ->addViolation(); + } + } + + // Service validation + if (null === $participant->skiPass) { + $this->context->buildViolation('Bitte auswählen') + ->atPath('participant.skiPass') + ->addViolation(); + } + + if (null === $participant->transportationOutbound) { + $this->context->buildViolation('Bitte auswählen') + ->atPath('participant.transportationOutbound') + ->addViolation(); + } + + if (null === $participant->transportationInbound) { + $this->context->buildViolation('Bitte auswählen') + ->atPath('participant.transportationInbound') + ->addViolation(); + } + + if (null === $participant->insurance) { + $this->context->buildViolation('Bitte auswählen') + ->atPath('participant.insurance') + ->addViolation(); + } + } + + /** + * Validates that a field is not empty (null or blank string). + * + * @param mixed $value The field value to check + * @param string $path The property path for the violation + * @param string $message The violation message + */ + private function validateRequired(mixed $value, string $path, string $message): void + { + if (null === $value || '' === trim((string) $value)) { + $this->context->buildViolation($message) + ->atPath($path) + ->addViolation(); } } } diff --git a/src/Validator/Constraints/ParticipantValidator.php b/src/Validator/Constraints/ParticipantValidator.php index 36e9c8b..baf5fba 100644 --- a/src/Validator/Constraints/ParticipantValidator.php +++ b/src/Validator/Constraints/ParticipantValidator.php @@ -14,7 +14,6 @@ class ParticipantValidator extends ConstraintValidator $participant = $value; $this->assertBodyMeasurementsValid($participant); - $this->assertTransportationSelected($participant); $this->assertPickupSelected($participant); } @@ -34,23 +33,6 @@ class ParticipantValidator extends ConstraintValidator } } - public function assertTransportationSelected(ParticipantDto $participant): void - { - // no transportation services required for canceled participants - if (true === $participant->isCanceled()) { - return; - } - - foreach (['transportationOutbound', 'transportationInbound'] as $property) { - if (null === $participant->{$property}) { - $this->context->buildViolation('Bitte angeben') - ->atPath($property) - ->addViolation() - ; - } - } - } - public function assertPickupSelected(ParticipantDto $participant): void { // Check if either outbound or inbound transportation is bus diff --git a/templates/booking/_cancel_link.html.twig b/templates/booking/_cancel_link.html.twig new file mode 100644 index 0000000..e8a5412 --- /dev/null +++ b/templates/booking/_cancel_link.html.twig @@ -0,0 +1,5 @@ + diff --git a/templates/booking/create/authenticate.html.twig b/templates/booking/create/authenticate.html.twig index ac615f3..6121c8a 100644 --- a/templates/booking/create/authenticate.html.twig +++ b/templates/booking/create/authenticate.html.twig @@ -100,4 +100,6 @@ + + {% include 'booking/_cancel_link.html.twig' %} {% endblock %} \ No newline at end of file diff --git a/templates/booking/create/step_1.html.twig b/templates/booking/create/step_1.html.twig index 79a0478..1115b40 100644 --- a/templates/booking/create/step_1.html.twig +++ b/templates/booking/create/step_1.html.twig @@ -75,4 +75,6 @@ {% endblock %} + + {% include 'booking/_cancel_link.html.twig' %} {% endblock %} diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index 398ec38..98742c8 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -72,4 +72,6 @@ {% endblock %} + + {% include 'booking/_cancel_link.html.twig' %} {% endblock %} diff --git a/templates/booking/create/step_3.html.twig b/templates/booking/create/step_3.html.twig index b4f803b..813be67 100644 --- a/templates/booking/create/step_3.html.twig +++ b/templates/booking/create/step_3.html.twig @@ -65,4 +65,6 @@ } %} + + {% include 'booking/_cancel_link.html.twig' %} {% endblock %} diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index c1ead32..998175f 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -426,4 +426,6 @@ {{ form_end(form) }} + + {% include 'booking/_cancel_link.html.twig' %} {% endblock %} diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index 6282456..e76d48e 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -253,11 +253,24 @@ class ParticipantEditDtoTest extends TestCase $bookingDto = new BookingDto($travel, 1); // hotelId must be int + // Create mock booking to simulate edit mode + $mockBooking = new \App\BusProNet\Model\Booking(); + $bookingDto->booking = $mockBooking; + + // Create valid participants with all required fields for strict validation $participant1 = $this->createAdultParticipant('duplicate@example.com'); $participant1->index = 0; + $participant1->mutable = false; // Immutable - strict validation applies + $participant1->mobile = '+49 123 456789'; // Required for applicant + $participant1->address = new \App\BusProNet\Model\Address(); + $participant1->address->street = 'Test Street 1'; + $participant1->address->postCode = '12345'; + $participant1->address->city = 'Test City'; + $participant1->address->country = 'DE'; $participant2 = $this->createAdultParticipant('duplicate@example.com'); $participant2->index = 1; + $participant2->mutable = false; $bookingDto->participants = [$participant1, $participant2]; @@ -268,7 +281,7 @@ class ParticipantEditDtoTest extends TestCase $violations = $this->validator->validate($wrapper, null, ['booking_edit']); - // Should have uniqueness violation in edit mode as well + // Should have uniqueness violation (edit mode with immutable applicant = strict validation) $this->assertCount(1, $violations); } @@ -357,6 +370,7 @@ class ParticipantEditDtoTest extends TestCase $participant->skiPass = $this->createMockService(); $participant->transportationOutbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService(); + $participant->insurance = $this->createMockInsurance(); return $participant; } @@ -379,6 +393,7 @@ class ParticipantEditDtoTest extends TestCase $participant->skiPass = $this->createMockService(); $participant->transportationOutbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService(); + $participant->insurance = $this->createMockInsurance(); return $participant; } @@ -404,4 +419,14 @@ class ParticipantEditDtoTest extends TestCase return $service; } + + private function createMockInsurance(): \App\BusProNet\Model\Insurance + { + $insurance = new \App\BusProNet\Model\Insurance(); + $insurance->id = '1'; + $insurance->label = 'Test Insurance'; + $insurance->price = 10.0; + + return $insurance; + } } diff --git a/tests/Validator/Constraints/ParticipantValidatorTest.php b/tests/Validator/Constraints/ParticipantValidatorTest.php index 32ca795..45f069a 100644 --- a/tests/Validator/Constraints/ParticipantValidatorTest.php +++ b/tests/Validator/Constraints/ParticipantValidatorTest.php @@ -77,7 +77,7 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase $this->assertNoViolation(); } - public function testCanceledParticipantSkipsTransportationValidation(): void + public function testCanceledParticipantSkipsValidation(): void { $participant = $this->createValidParticipant(); $participant->status = 'S'; // Canceled @@ -89,22 +89,6 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase $this->assertNoViolation(); } - public function testActiveParticipantWithoutTransportationFailsValidation(): void - { - $participant = $this->createValidParticipant(); - $participant->status = 'F'; // Active - $participant->transportationOutbound = null; - $participant->transportationInbound = null; - - $this->validator->validate($participant, new Participant()); - - $this->buildViolation('Bitte angeben') - ->atPath('property.path.transportationOutbound') - ->buildNextViolation('Bitte angeben') - ->atPath('property.path.transportationInbound') - ->assertRaised(); - } - public function testParticipantWithBusTransportationButNoPickupFailsValidation(): void { $participant = $this->createValidParticipant();