feat: improved mutability checks for personal data in edit or create mode
This commit is contained in:
@@ -69,6 +69,20 @@ class BookingDataProcessor
|
|||||||
$participantData = ParticipantDto::fromPersonalData($participant);
|
$participantData = ParticipantDto::fromPersonalData($participant);
|
||||||
$participantData->index = $index;
|
$participantData->index = $index;
|
||||||
|
|
||||||
|
// First participant (applicant): copy address from applicant if participant address is empty
|
||||||
|
// BPN API may return full address only in <anmelder> but minimal/empty address in <teilnehmer id="1">
|
||||||
|
// 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
|
// Extract service selections from booking and assign to participant DTO
|
||||||
$participantData->courses = $booking
|
$participantData->courses = $booking
|
||||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
|
->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.
|
* 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.
|
* 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 ParticipantDto $participant The participant data from the form
|
||||||
* @param Booking $bookingData The booking data object to update
|
* @param Booking $bookingData The booking data object to update
|
||||||
* @param Travel $travelData The travel data containing available insurances
|
* @param Travel $travelData The travel data containing available insurances
|
||||||
*/
|
*/
|
||||||
private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,13 +777,9 @@ class BookingDataProcessor
|
|||||||
'nationalitaet' => $firstParticipant->nationality ?? '',
|
'nationalitaet' => $firstParticipant->nationality ?? '',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Include BPN IDs for linking to existing records (authenticated users)
|
// DO NOT include personId or addressId in create mode
|
||||||
if (null !== $firstParticipant->addressId) {
|
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||||
$payload['anmelder']['idadresse'] = $firstParticipant->addressId;
|
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||||
}
|
|
||||||
if (null !== $firstParticipant->personId) {
|
|
||||||
$payload['anmelder']['idadresseperson'] = $firstParticipant->personId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $firstParticipant->dateOfBirth) {
|
if (null !== $firstParticipant->dateOfBirth) {
|
||||||
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
|
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
|
||||||
@@ -798,13 +811,9 @@ class BookingDataProcessor
|
|||||||
'nationalitaet' => $participant->nationality ?? '',
|
'nationalitaet' => $participant->nationality ?? '',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Include BPN IDs for linking to existing records (authenticated users)
|
// DO NOT include personId or addressId in create mode
|
||||||
if (null !== $participant->addressId) {
|
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||||
$participantData['idadresse'] = $participant->addressId;
|
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||||
}
|
|
||||||
if (null !== $participant->personId) {
|
|
||||||
$participantData['idadresseperson'] = $participant->personId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->dateOfBirth) {
|
if (null !== $participant->dateOfBirth) {
|
||||||
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
||||||
@@ -1085,6 +1094,7 @@ class BookingDataProcessor
|
|||||||
* Collects insurance mappings.
|
* Collects insurance mappings.
|
||||||
*
|
*
|
||||||
* CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow.
|
* 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
|
* @return array<string, array<int>> Map of insurance ID to participant IDs
|
||||||
*/
|
*/
|
||||||
@@ -1095,7 +1105,8 @@ class BookingDataProcessor
|
|||||||
foreach ($bookingDto->participants as $index => $participant) {
|
foreach ($bookingDto->participants as $index => $participant) {
|
||||||
$participantId = $index + 1;
|
$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;
|
$insuranceMap[$participant->insurance->id][] = $participantId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\BusProNet\Model;
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a physical address with street, postal code, city, and country information.
|
* 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
|
class Address
|
||||||
{
|
{
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
|
||||||
public ?string $street = null;
|
public ?string $street = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
|
||||||
public ?string $postCode = null;
|
public ?string $postCode = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
|
||||||
public ?string $city = null;
|
public ?string $city = null;
|
||||||
|
|
||||||
public ?string $district = null;
|
public ?string $district = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
|
||||||
public ?string $country = null;
|
public ?string $country = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
*/
|
*/
|
||||||
class Insurance
|
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'])]
|
#[Groups(['api:single', 'api:list'])]
|
||||||
public ?string $id = null;
|
public ?string $id = null;
|
||||||
|
|
||||||
@@ -180,4 +189,17 @@ class Insurance
|
|||||||
|
|
||||||
return array_values(array_unique($urls));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,26 @@ class IndexController extends AbstractController
|
|||||||
return $agency->id;
|
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.
|
* Displays user-friendly error messages for booking initialization failures.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ class IndexController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh availability data
|
// Refresh availability data
|
||||||
$availabilities = $this->travelDataService->getAvailabilityDataCached($bookingDto->travel->id);
|
$availabilities = $this->travelDataService->getAvailabilityData($bookingDto->travel->id, cached: true);
|
||||||
if (null !== $availabilities) {
|
if (null !== $availabilities) {
|
||||||
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
|
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
|
||||||
}
|
}
|
||||||
@@ -505,7 +505,7 @@ class IndexController extends AbstractController
|
|||||||
private function refreshFromSession(BookingDto $formData): BookingDto
|
private function refreshFromSession(BookingDto $formData): BookingDto
|
||||||
{
|
{
|
||||||
// Refresh availability data
|
// Refresh availability data
|
||||||
$availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id);
|
$availabilities = $this->travelDataService->getAvailabilityData($formData->travel->id, cached: true);
|
||||||
if (null !== $availabilities) {
|
if (null !== $availabilities) {
|
||||||
$this->travelDataService->patchAvailabilities($formData->travel, $availabilities);
|
$this->travelDataService->patchAvailabilities($formData->travel, $availabilities);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Validator\Constraints as AppAssert;
|
|||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
||||||
#[AppAssert\ApplicantAddress(groups: ['booking_create'])]
|
|
||||||
class ParticipantDto
|
class ParticipantDto
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -42,10 +41,8 @@ class ParticipantDto
|
|||||||
public bool $mutable = false;
|
public bool $mutable = false;
|
||||||
public bool $touched = false;
|
public bool $touched = false;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
|
||||||
public ?string $firstName = null;
|
public ?string $firstName = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
|
||||||
public ?string $lastName = null;
|
public ?string $lastName = null;
|
||||||
public ?string $title = null;
|
public ?string $title = null;
|
||||||
public ?string $gender = null;
|
public ?string $gender = null;
|
||||||
@@ -55,10 +52,8 @@ class ParticipantDto
|
|||||||
public ?string $shoeSize = null;
|
public ?string $shoeSize = null;
|
||||||
public ?string $weight = null;
|
public ?string $weight = null;
|
||||||
|
|
||||||
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
|
||||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
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'])]
|
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
||||||
public ?string $email = null;
|
public ?string $email = null;
|
||||||
|
|
||||||
@@ -76,7 +71,6 @@ class ParticipantDto
|
|||||||
public array $courses = [];
|
public array $courses = [];
|
||||||
public array $additionalServices = [];
|
public array $additionalServices = [];
|
||||||
|
|
||||||
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create'])]
|
|
||||||
public ?Service $skiPass = null;
|
public ?Service $skiPass = null;
|
||||||
|
|
||||||
public array $board = [];
|
public array $board = [];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Form\Model;
|
namespace App\Form\Model;
|
||||||
|
|
||||||
|
use App\Validator\Constraints as AppAssert;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
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
|
* the participant being edited and the full booking context needed for
|
||||||
* cross-participant validation.
|
* cross-participant validation.
|
||||||
*/
|
*/
|
||||||
|
#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])]
|
||||||
class ParticipantEditDto
|
class ParticipantEditDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -10,38 +10,44 @@ use App\Form\Service\Contract\FieldConditionInterface;
|
|||||||
/**
|
/**
|
||||||
* Condition that determines if personal data fields should be hidden for authenticated users.
|
* 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),
|
* ⚠️ IMPORTANT: This condition is used in CREATE MODE ONLY to protect authenticated
|
||||||
* their personal data should not be editable during the booking process. Changes to
|
* applicant data during booking creation. It is NOT used in edit mode.
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* Note: In edit mode, the BPN API may not return addressId in booking responses,
|
* In CREATE mode:
|
||||||
* so we rely on personId alone to identify authenticated users. The personId is
|
* - When a logged-in user creates a booking, their personal data is prepopulated from
|
||||||
* sufficient to link a participant to an existing BPN account.
|
* 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:
|
* When this condition is satisfied (returns true), the template should:
|
||||||
* - Hide the form fields for personal data
|
* - Hide the form fields for personal data
|
||||||
* - Display the values as static, read-only text
|
* - Display the values as static, read-only text via the field_or_static macro
|
||||||
*
|
|
||||||
* This follows the same pattern as bulk insurance booking.
|
|
||||||
*/
|
*/
|
||||||
class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
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,
|
* Returns true when the participant has a personId set, indicating they are
|
||||||
* indicating they are linked to an existing BPN account. When true,
|
* a logged-in user whose data was prepopulated from their BPN account.
|
||||||
* personal data fields should be hidden and displayed as static text.
|
* 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 int $participantIndex The index of the participant being evaluated
|
||||||
* @param array<string, mixed> $formData Current form data (unused)
|
* @param array<string, mixed> $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
|
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||||
{
|
{
|
||||||
@@ -50,17 +56,16 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide personal data fields if participant has BPN person ID
|
// Hide personal data fields if participant has BPN person ID (authenticated user in create mode)
|
||||||
// In edit mode, bookings may not include addressId, so we check personId only
|
// The personId is set during prepopulation when a logged-in user starts creating a booking
|
||||||
// The personId alone is sufficient to identify a participant linked to a BPN account
|
|
||||||
return null !== $participant->personId;
|
return null !== $participant->personId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns field names that this condition depends on.
|
* Returns field names that this condition depends on.
|
||||||
*
|
*
|
||||||
* This condition is based on addressId/personId which are set during prepopulation
|
* This condition is based on personId which is set during prepopulation
|
||||||
* and don't change during the form interaction, so no field dependencies.
|
* and doesn't change during form interaction, so no field dependencies.
|
||||||
*
|
*
|
||||||
* @return string[] Empty array - no field dependencies
|
* @return string[] Empty array - no field dependencies
|
||||||
*/
|
*/
|
||||||
@@ -76,6 +81,6 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
|||||||
*/
|
*/
|
||||||
public function getDescription(): string
|
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)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Service\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Service\Contract\FieldConditionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition that determines if personal data fields should be hidden based on BPN mutability flag.
|
||||||
|
*
|
||||||
|
* This condition is used in EDIT MODE ONLY to respect the BPN API's per-participant
|
||||||
|
* mutability flag (`aenderungmoeglich`). When a participant's data is not mutable
|
||||||
|
* (mutable=false), personal data fields should be hidden and displayed as static text.
|
||||||
|
*
|
||||||
|
* This differs from AuthenticatedUserPersonalDataCondition which is used in CREATE MODE
|
||||||
|
* to protect authenticated applicant data.
|
||||||
|
*
|
||||||
|
* In edit mode, BPN determines mutability based on business rules (e.g., payment status,
|
||||||
|
* booking state, etc.). We must respect this flag to prevent users from attempting to
|
||||||
|
* modify data that BPN will reject.
|
||||||
|
*
|
||||||
|
* When this condition is satisfied (returns true), the template renders fields as
|
||||||
|
* static text via the `field_or_static` macro instead of form inputs.
|
||||||
|
*/
|
||||||
|
class PersonalDataMutabilityCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluates if personal data fields should be hidden (not editable).
|
||||||
|
*
|
||||||
|
* Returns true when the participant's mutable flag is false, indicating
|
||||||
|
* that BPN does not allow modifications to this participant's data.
|
||||||
|
* When true, personal data fields should be hidden and displayed as static text.
|
||||||
|
*
|
||||||
|
* @param BookingDto $bookingDto The current booking data
|
||||||
|
* @param int $participantIndex The index of the participant being evaluated
|
||||||
|
* @param array<string, mixed> $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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition;
|
|||||||
use App\Form\Service\Condition\CompositeCondition;
|
use App\Form\Service\Condition\CompositeCondition;
|
||||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||||
use App\Form\Service\Condition\FieldValueCondition;
|
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\PickupsMutabilityCondition;
|
||||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||||
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
||||||
@@ -44,13 +44,21 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
|||||||
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
|
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
|
||||||
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
|
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
|
||||||
|
|
||||||
// Authenticated user personal data protection
|
// Personal data protection in edit mode has TWO rules:
|
||||||
// Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records)
|
// 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();
|
$authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition();
|
||||||
|
$personalDataMutabilityCondition = new PersonalDataMutabilityCondition();
|
||||||
|
$personalDataHiddenCondition = CompositeCondition::or(
|
||||||
|
CompositeCondition::and($applicantCondition, $authenticatedUserCondition),
|
||||||
|
$personalDataMutabilityCondition
|
||||||
|
);
|
||||||
|
|
||||||
// Make all personal data fields readonly if participant not mutable
|
// Render all personal data fields as static text if authenticated user OR BPN indicates not mutable
|
||||||
// OR static text if participant is linked to BPN account (authenticated user)
|
// Using 'static_text' state (not 'hidden') so template renders values as static text
|
||||||
// Note: First participant is now treated as independent from applicant and can be edited
|
|
||||||
$personalDataFields = [
|
$personalDataFields = [
|
||||||
'firstName',
|
'firstName',
|
||||||
'lastName',
|
'lastName',
|
||||||
@@ -62,36 +70,30 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
|||||||
];
|
];
|
||||||
foreach ($personalDataFields as $field) {
|
foreach ($personalDataFields as $field) {
|
||||||
$this->fieldStateConditions[$field] = [
|
$this->fieldStateConditions[$field] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
'readonly' => CompositeCondition::not(new MutabilityCondition()),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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'] = [
|
$this->fieldStateConditions['address'] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
'readonly' => CompositeCondition::not(new MutabilityCondition()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 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'] = [
|
$this->fieldStateConditions['address.street'] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->fieldStateConditions['address.postCode'] = [
|
$this->fieldStateConditions['address.postCode'] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->fieldStateConditions['address.city'] = [
|
$this->fieldStateConditions['address.city'] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->fieldStateConditions['address.country'] = [
|
$this->fieldStateConditions['address.country'] = [
|
||||||
'static_text' => $authenticatedUserCondition,
|
'static_text' => $personalDataHiddenCondition,
|
||||||
];
|
|
||||||
|
|
||||||
$this->fieldStateConditions['address.district'] = [
|
|
||||||
'static_text' => $authenticatedUserCondition,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Conditional visibility for service fields (same as create flow)
|
// Conditional visibility for service fields (same as create flow)
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => false,
|
'multiple' => false,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
|
||||||
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
|
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
|
||||||
'choice_value' => 'id',
|
'choice_value' => 'id',
|
||||||
'choice_label' => function (?Insurance $insurance) {
|
'choice_label' => function (?Insurance $insurance) {
|
||||||
@@ -501,7 +502,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
return $label;
|
return $label;
|
||||||
},
|
},
|
||||||
'placeholder' => 'Keine Versicherung gewünscht',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Future field providers would be added here, for example:
|
// 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.
|
* 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 BookingDto $bookingDto The booking DTO containing travel and participant data
|
||||||
* @param int $participantIndex The index of the participant to get eligible insurances for
|
* @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);
|
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||||
|
|
||||||
// Filter based on eligibility criteria for this participant
|
// Filter based on eligibility criteria for this participant
|
||||||
return $this->insuranceService->getEligibleInsurances(
|
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||||
$selectableInsurances,
|
$selectableInsurances,
|
||||||
$participant,
|
$participant,
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$travelPrice
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
|||||||
|
|
||||||
// Handle explicit new insurance selection from user
|
// Handle explicit new insurance selection from user
|
||||||
if ($isNewSelection) {
|
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);
|
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
||||||
|
|
||||||
if (null !== $selectedInsurance) {
|
if (null !== $selectedInsurance) {
|
||||||
|
|||||||
@@ -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.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class AppRuntime implements RuntimeExtensionInterface
|
|||||||
'S' => 'Stornierung',
|
'S' => 'Stornierung',
|
||||||
'O' => 'Option',
|
'O' => 'Option',
|
||||||
'U' => 'Umbuchung',
|
'U' => 'Umbuchung',
|
||||||
|
'A' => 'Anfrage',
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraint;
|
|
||||||
|
|
||||||
#[\Attribute]
|
|
||||||
class ApplicantAddress extends Constraint
|
|
||||||
{
|
|
||||||
public function getTargets(): string
|
|
||||||
{
|
|
||||||
return self::CLASS_CONSTRAINT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
|
||||||
|
|
||||||
use App\Form\Model\ParticipantDto;
|
|
||||||
use Symfony\Component\Validator\Constraint;
|
|
||||||
use Symfony\Component\Validator\ConstraintValidator;
|
|
||||||
|
|
||||||
class ApplicantAddressValidator extends ConstraintValidator
|
|
||||||
{
|
|
||||||
public function validate(mixed $value, Constraint $constraint): void
|
|
||||||
{
|
|
||||||
/** @var ParticipantDto $participant */
|
|
||||||
$participant = $value;
|
|
||||||
|
|
||||||
// Only validate for the first participant (applicant in create mode)
|
|
||||||
if (false === $participant->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()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
namespace App\Validator\Constraints;
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraint;
|
use Symfony\Component\Validator\Constraint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates required fields based on booking mode and applicant mutability.
|
||||||
|
*
|
||||||
|
* This constraint implements mode-aware validation that differentiates between:
|
||||||
|
* - Create mode: All fields mandatory
|
||||||
|
* - Edit mode (immutable): All fields mandatory
|
||||||
|
* - Edit mode (mutable): Only format validation, fields optional
|
||||||
|
*
|
||||||
|
* The applicant's (index 0) mutability flag controls validation for the entire booking.
|
||||||
|
*/
|
||||||
#[\Attribute]
|
#[\Attribute]
|
||||||
class Booking extends Constraint
|
class Booking extends Constraint
|
||||||
{
|
{
|
||||||
public string $message = 'Bitte prüfe deine Angaben.';
|
|
||||||
|
|
||||||
public function getTargets(): array|string
|
public function getTargets(): array|string
|
||||||
{
|
{
|
||||||
return static::CLASS_CONSTRAINT;
|
return static::CLASS_CONSTRAINT;
|
||||||
|
|||||||
@@ -1,39 +1,167 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
namespace App\Validator\Constraints;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
|
||||||
use App\BusProNet\Model\BookingUpdate;
|
|
||||||
use App\BusProNet\Model\Notification;
|
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Model\ParticipantEditDto;
|
||||||
use Symfony\Component\Validator\Constraint;
|
use Symfony\Component\Validator\Constraint;
|
||||||
use Symfony\Component\Validator\ConstraintValidator;
|
use Symfony\Component\Validator\ConstraintValidator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates required fields with mode-aware and mutability-aware rules.
|
||||||
|
*
|
||||||
|
* Validation Strategy:
|
||||||
|
* - Create mode: STRICT (all fields required + format validation)
|
||||||
|
* - Edit mode + applicant immutable: STRICT (all fields required + format validation)
|
||||||
|
* - Edit mode + applicant mutable: RELAXED (only format validation, fields optional)
|
||||||
|
*
|
||||||
|
* The applicant's (index 0) mutability flag determines validation strictness for ALL participants.
|
||||||
|
* This ensures consistent validation across the entire booking based on whether the booking
|
||||||
|
* can be modified freely or has restrictions imposed by the booking system.
|
||||||
|
*
|
||||||
|
* Strict Validation (Create + Edit Immutable):
|
||||||
|
* - Personal data: firstName, lastName, dateOfBirth, email, address (all subfields) - REQUIRED
|
||||||
|
* - Services: skiPass, transportationOutbound, transportationInbound, insurance - REQUIRED
|
||||||
|
* - Format constraints (Email, date formats) also apply
|
||||||
|
*
|
||||||
|
* Relaxed Validation (Edit Mutable):
|
||||||
|
* - All fields OPTIONAL (can be null/empty)
|
||||||
|
* - Format constraints still apply IF values are provided
|
||||||
|
* - Allows partial updates without forcing complete data re-entry
|
||||||
|
*/
|
||||||
class BookingValidator extends ConstraintValidator
|
class BookingValidator extends ConstraintValidator
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ApiClient $apiClient)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function validate(mixed $value, Constraint $constraint): void
|
public function validate(mixed $value, Constraint $constraint): void
|
||||||
{
|
{
|
||||||
/** @var BookingDto $booking */
|
if (!$value instanceof ParticipantEditDto) {
|
||||||
$bookingData = $value;
|
return;
|
||||||
|
|
||||||
$result = $this->apiClient->updateBooking($bookingData);
|
|
||||||
|
|
||||||
if ($result instanceof Notification) {
|
|
||||||
$this->context
|
|
||||||
->buildViolation($result->message)
|
|
||||||
->addViolation()
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result instanceof BookingUpdate && false === $result->valid) {
|
$participant = $value->participant;
|
||||||
$this->context
|
$bookingContext = $value->bookingContext;
|
||||||
->buildViolation($constraint->message)
|
|
||||||
->addViolation()
|
// 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ class ParticipantValidator extends ConstraintValidator
|
|||||||
$participant = $value;
|
$participant = $value;
|
||||||
|
|
||||||
$this->assertBodyMeasurementsValid($participant);
|
$this->assertBodyMeasurementsValid($participant);
|
||||||
$this->assertTransportationSelected($participant);
|
|
||||||
$this->assertPickupSelected($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
|
public function assertPickupSelected(ParticipantDto $participant): void
|
||||||
{
|
{
|
||||||
// Check if either outbound or inbound transportation is bus
|
// Check if either outbound or inbound transportation is bus
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="mt-8 pt-4 border-t border-gray-200 text-center">
|
||||||
|
<a href="{{ path('app_booking_cancel') }}" class="text-sm text-gray-600 hover:text-gray-900 hover:underline">
|
||||||
|
Buchung abbrechen und zur Startseite zurückkehren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -100,4 +100,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'booking/_cancel_link.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -75,4 +75,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'booking/_cancel_link.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -72,4 +72,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'booking/_cancel_link.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -65,4 +65,6 @@
|
|||||||
} %}
|
} %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'booking/_cancel_link.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -426,4 +426,6 @@
|
|||||||
|
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'booking/_cancel_link.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -253,11 +253,24 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1); // hotelId must be int
|
$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('[email protected]');
|
$participant1 = $this->createAdultParticipant('[email protected]');
|
||||||
$participant1->index = 0;
|
$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('[email protected]');
|
$participant2 = $this->createAdultParticipant('[email protected]');
|
||||||
$participant2->index = 1;
|
$participant2->index = 1;
|
||||||
|
$participant2->mutable = false;
|
||||||
|
|
||||||
$bookingDto->participants = [$participant1, $participant2];
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
@@ -268,7 +281,7 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
|
|
||||||
$violations = $this->validator->validate($wrapper, null, ['booking_edit']);
|
$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);
|
$this->assertCount(1, $violations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +370,7 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
$participant->skiPass = $this->createMockService();
|
$participant->skiPass = $this->createMockService();
|
||||||
$participant->transportationOutbound = $this->createMockService();
|
$participant->transportationOutbound = $this->createMockService();
|
||||||
$participant->transportationInbound = $this->createMockService();
|
$participant->transportationInbound = $this->createMockService();
|
||||||
|
$participant->insurance = $this->createMockInsurance();
|
||||||
|
|
||||||
return $participant;
|
return $participant;
|
||||||
}
|
}
|
||||||
@@ -379,6 +393,7 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
$participant->skiPass = $this->createMockService();
|
$participant->skiPass = $this->createMockService();
|
||||||
$participant->transportationOutbound = $this->createMockService();
|
$participant->transportationOutbound = $this->createMockService();
|
||||||
$participant->transportationInbound = $this->createMockService();
|
$participant->transportationInbound = $this->createMockService();
|
||||||
|
$participant->insurance = $this->createMockInsurance();
|
||||||
|
|
||||||
return $participant;
|
return $participant;
|
||||||
}
|
}
|
||||||
@@ -404,4 +419,14 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
|
|
||||||
return $service;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase
|
|||||||
$this->assertNoViolation();
|
$this->assertNoViolation();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCanceledParticipantSkipsTransportationValidation(): void
|
public function testCanceledParticipantSkipsValidation(): void
|
||||||
{
|
{
|
||||||
$participant = $this->createValidParticipant();
|
$participant = $this->createValidParticipant();
|
||||||
$participant->status = 'S'; // Canceled
|
$participant->status = 'S'; // Canceled
|
||||||
@@ -89,22 +89,6 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase
|
|||||||
$this->assertNoViolation();
|
$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
|
public function testParticipantWithBusTransportationButNoPickupFailsValidation(): void
|
||||||
{
|
{
|
||||||
$participant = $this->createValidParticipant();
|
$participant = $this->createValidParticipant();
|
||||||
|
|||||||
Reference in New Issue
Block a user