wip: modernized edit flow, fix insurance tier calculation
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -30,7 +30,7 @@ class BookingCreateStep1Type extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
'data_class' => BookingDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\ParticipantFieldHandlerRegistry;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
@@ -31,7 +31,7 @@ class BookingCreateStep2Type extends AbstractType
|
||||
*/
|
||||
public function onPreSetData(FormEvent $event): void
|
||||
{
|
||||
/** @var BookingCreateDto|null $data */
|
||||
/** @var BookingDto|null $data */
|
||||
$data = $event->getData();
|
||||
if (null === $data) {
|
||||
return;
|
||||
@@ -43,7 +43,7 @@ class BookingCreateStep2Type extends AbstractType
|
||||
/**
|
||||
* Handles dynamic participant form field updates on POST requests (e.g., from HTMX).
|
||||
*
|
||||
* This listener synchronizes the BookingCreateDto with the submitted participant data *before*
|
||||
* This listener synchronizes the BookingDto with the submitted participant data *before*
|
||||
* the form's children are processed. It then rebuilds the participants
|
||||
* field to ensure choice loaders are created with the fresh state.
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ class BookingCreateStep2Type extends AbstractType
|
||||
$form = $event->getForm();
|
||||
$submittedData = $event->getData();
|
||||
|
||||
/** @var BookingCreateDto $bookingDto */
|
||||
/** @var BookingDto $bookingDto */
|
||||
$bookingDto = $form->getData();
|
||||
|
||||
// Process field handlers and synchronize submitted data with cleaned DTO state
|
||||
@@ -81,7 +81,7 @@ class BookingCreateStep2Type extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
'data_class' => BookingDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
@@ -69,7 +69,7 @@ class BookingCreateStep3Type extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
'data_class' => BookingDto::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class BookingCreateStep3Type extends AbstractType
|
||||
*
|
||||
* Direct debit is only available if the travel starts at least 14 days from now.
|
||||
*/
|
||||
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
|
||||
private function isDebitAvailable(BookingDto $bookingDto): bool
|
||||
{
|
||||
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -31,7 +31,7 @@ class BookingCreateStep4Type extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
'data_class' => BookingDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
class BookingCreateDto implements BookingDtoInterface
|
||||
{
|
||||
public int $currentStep = 1;
|
||||
|
||||
/**
|
||||
* @var array<int, RoomSelectionDto>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
public array $roomSelections = [];
|
||||
|
||||
/**
|
||||
* @var array<int, ParticipantDto>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
public array $participants = [];
|
||||
|
||||
#[Assert\Choice(
|
||||
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
|
||||
message: 'Bitte wählen Sie eine gültige Zahlungsart.'
|
||||
)]
|
||||
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
|
||||
|
||||
public ?BankAccountDto $bankAccount = null;
|
||||
|
||||
public ?int $agencyId = null;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMode(): string
|
||||
{
|
||||
return BookingDtoInterface::MODE_CREATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, RoomSelectionDto>
|
||||
*/
|
||||
public function getSelectedRooms(): array
|
||||
{
|
||||
return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) {
|
||||
return 0 < $roomSelection->quantity;
|
||||
});
|
||||
}
|
||||
|
||||
public function getParticipants(): array
|
||||
{
|
||||
return $this->participants;
|
||||
}
|
||||
|
||||
public function hasParticipant(int $index): bool
|
||||
{
|
||||
return isset($this->participants[$index]);
|
||||
}
|
||||
|
||||
public function getParticipant(int $index): ?ParticipantDto
|
||||
{
|
||||
return $this->participants[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this is a family booking based on participant age distribution.
|
||||
*
|
||||
* A family booking is defined as:
|
||||
* - 1 or 2 participants aged 18 or older (adults)
|
||||
* - At least 1 participant younger than 18 (children)
|
||||
*
|
||||
* @return bool True if this qualifies as a family booking
|
||||
*/
|
||||
public function isFamilyBooking(): bool
|
||||
{
|
||||
$adults = 0; // Count of participants >= 18 years
|
||||
$children = 0; // Count of participants < 18 years
|
||||
|
||||
// Use travel start date for age calculation
|
||||
$travelStartDate = $this->travel->dateFrom;
|
||||
|
||||
foreach ($this->participants as $participant) {
|
||||
$age = $participant->getAge($travelStartDate);
|
||||
|
||||
if (null === $age) {
|
||||
continue; // Skip participants without birth date
|
||||
}
|
||||
|
||||
if ($age >= 18) {
|
||||
++$adults;
|
||||
} else {
|
||||
++$children;
|
||||
}
|
||||
}
|
||||
|
||||
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
|
||||
}
|
||||
|
||||
#[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])]
|
||||
public function validateRoomSelection(ExecutionContextInterface $context): void
|
||||
{
|
||||
$selectedRooms = $this->getSelectedRooms();
|
||||
|
||||
if (0 === count($selectedRooms)) {
|
||||
$context->buildViolation('Bitte mindestens ein Zimmer/Bett auswählen')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateBankAccount(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->bankAccount) {
|
||||
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
|
||||
->atPath('bankAccount')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate IBAN
|
||||
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
|
||||
$context->buildViolation('Bitte geben Sie Ihre IBAN ein.')
|
||||
->atPath('bankAccount.iban')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
// Validate account holder
|
||||
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
|
||||
$context->buildViolation('Bitte geben Sie den Kontoinhaber ein.')
|
||||
->atPath('bankAccount.accountHolder')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
// Validate SEPA mandate
|
||||
if (false === $this->bankAccount->sepaMandateAccepted) {
|
||||
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.')
|
||||
->atPath('bankAccount.sepaMandateAccepted')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
/**
|
||||
* Interface for unified access to booking data across create and edit workflows.
|
||||
*
|
||||
* This interface provides a consistent API for accessing common booking data
|
||||
* regardless of whether the data comes from a creation workflow (BookingCreateDto)
|
||||
* or an edit workflow (BookingEditDto). This enables code reuse and simplifies
|
||||
* form field handlers and state management systems.
|
||||
*/
|
||||
interface BookingDtoInterface
|
||||
{
|
||||
public const MODE_CREATE = 'create';
|
||||
public const MODE_EDIT = 'edit';
|
||||
|
||||
/**
|
||||
* Gets the booking mode (create or edit).
|
||||
*
|
||||
* @return string One of MODE_CREATE or MODE_EDIT constants
|
||||
*/
|
||||
public function getMode(): string;
|
||||
|
||||
/**
|
||||
* Gets all participants in the booking.
|
||||
*
|
||||
* @return array<int, ParticipantDto> Array of participant DTOs indexed by participant index
|
||||
*/
|
||||
public function getParticipants(): array;
|
||||
|
||||
/**
|
||||
* Checks if a participant exists at the given index.
|
||||
*
|
||||
* @param int $index The participant index to check
|
||||
*
|
||||
* @return bool True if a participant exists at the given index
|
||||
*/
|
||||
public function hasParticipant(int $index): bool;
|
||||
|
||||
/**
|
||||
* Gets a participant by index.
|
||||
*
|
||||
* @param int $index The participant index
|
||||
*
|
||||
* @return ParticipantDto|null The participant DTO or null if not found
|
||||
*/
|
||||
public function getParticipant(int $index): ?ParticipantDto;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class BookingEditDto implements BookingDtoInterface
|
||||
{
|
||||
/**
|
||||
* @var array<int, ParticipantDto>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
public array $participants = [];
|
||||
|
||||
public function __construct(public Booking $booking, public Travel $travel)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMode(): string
|
||||
{
|
||||
return BookingDtoInterface::MODE_EDIT;
|
||||
}
|
||||
|
||||
public static function fromBooking(Booking $booking, Travel $travel): static
|
||||
{
|
||||
$instance = new static($booking, $travel);
|
||||
|
||||
$instance->booking = $booking;
|
||||
$instance->travel = $travel;
|
||||
|
||||
foreach ($booking->participants as $index => $participant) {
|
||||
/** @var PersonalData $participant */
|
||||
// For the first participant (applicant), use applicant data instead of participant data
|
||||
$personalData = 0 === $index && null !== $booking->applicant ? $booking->applicant : $participant;
|
||||
$participantData = ParticipantDto::fromPersonalData($personalData);
|
||||
$participantData->index = $index;
|
||||
|
||||
$participantData->courses = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
|
||||
|
||||
// Skipass is single selection - use dedicated method
|
||||
$participantData->skiPass = $booking->getSkiPassForParticipant($index);
|
||||
|
||||
$participantData->additionalServices = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL);
|
||||
$participantData->board = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD);
|
||||
$participantData->rentals = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS);
|
||||
// Transportation services using improved direction mapping
|
||||
// Direction mapping handles BusProNet's inconsistent codes (H <=> HIN, R <=> RUECK)
|
||||
$outboundTransportation = $booking
|
||||
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING);
|
||||
$inboundTransportation = $booking
|
||||
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING);
|
||||
|
||||
// Set new improved property names
|
||||
$participantData->transportationOutbound = $outboundTransportation;
|
||||
$participantData->transportationInbound = $inboundTransportation;
|
||||
|
||||
// Pickup handling (currently only supports outbound pickup)
|
||||
$pickup = $booking->getPickupForParticipant($index);
|
||||
$participantData->pickup = $pickup;
|
||||
|
||||
// Insurance - get insurance for participant
|
||||
$insurance = $booking->getInsuranceForParticipant($index);
|
||||
$participantData->insurance = $insurance;
|
||||
|
||||
// Room assignment - extract from booking room mappings
|
||||
$room = $booking->getRoomForParticipant($index);
|
||||
$participantData->assignedRoomId = $room?->id;
|
||||
|
||||
$instance->participants[$index] = $participantData;
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function isCanceled(): bool
|
||||
{
|
||||
return 'S' === $this->booking->status;
|
||||
}
|
||||
|
||||
public function isOption(): bool
|
||||
{
|
||||
return 'O' === $this->booking->status;
|
||||
}
|
||||
|
||||
public function getParticipants(): array
|
||||
{
|
||||
return $this->participants;
|
||||
}
|
||||
|
||||
public function hasParticipant(int $index): bool
|
||||
{
|
||||
return isset($this->participants[$index]);
|
||||
}
|
||||
|
||||
public function getParticipant(int $index): ?ParticipantDto
|
||||
{
|
||||
return $this->participants[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all selected rooms for the booking (edit context).
|
||||
*
|
||||
* In edit mode, rooms are fixed and not selectable - returns empty array.
|
||||
* Participant count should be derived from actual participants, not room selections.
|
||||
*
|
||||
* @return array<int, RoomSelectionDto>
|
||||
*/
|
||||
public function getSelectedRooms(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
@@ -95,7 +95,7 @@ class PaymentType extends AbstractType
|
||||
*
|
||||
* Direct debit is only available if the travel starts at least 14 days from now.
|
||||
*/
|
||||
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
|
||||
private function isDebitAvailable(BookingDto $bookingDto): bool
|
||||
{
|
||||
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
@@ -194,6 +194,16 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
'hidden' => FieldValueCondition::equals('parking', false),
|
||||
];
|
||||
|
||||
// Make mobile field required for applicant (participant index 0)
|
||||
$this->fieldStateConditions['mobile'] = [
|
||||
'required' => new ApplicantCondition(),
|
||||
];
|
||||
|
||||
// Make address field required for applicant (participant index 0)
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'required' => new ApplicantCondition(),
|
||||
];
|
||||
|
||||
// Example field state conditions would be registered here
|
||||
// For demonstration purposes, here are some example patterns:
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@ use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\BookingEditDto;
|
||||
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
|
||||
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
|
||||
use App\Service\InsuranceMatchingService;
|
||||
@@ -462,9 +460,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
* as the participant's selected skipass. This ensures rental equipment
|
||||
* is only available for the exact duration of the skipass.
|
||||
*
|
||||
* @param array $rentals Array of rental Service objects to filter
|
||||
* @param array $rentals Array of rental Service objects to filter
|
||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||
* @param int $participantIndex Index of the participant to evaluate
|
||||
* @param int $participantIndex Index of the participant to evaluate
|
||||
*
|
||||
* @return array Filtered array of rentals matching skipass duration
|
||||
*/
|
||||
@@ -587,15 +585,15 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
/**
|
||||
* Checks if a service should be rendered as read-only due to unavailability.
|
||||
*
|
||||
* @param Service $service The service to check
|
||||
* @param Service $service The service to check
|
||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||
* @param int $participantIndex Index of the participant currently selecting services
|
||||
* @param int $participantIndex Index of the participant currently selecting services
|
||||
*
|
||||
* @return bool True if the service should be read-only due to unavailability
|
||||
*/
|
||||
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||
{
|
||||
if (!$bookingDto instanceof BookingCreateDto) {
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
||||
// For non-create workflows, don't apply availability restrictions
|
||||
return false;
|
||||
}
|
||||
@@ -610,9 +608,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
* If no age evaluator is configured or participant has no birth date,
|
||||
* returns empty array to be handled by field visibility conditions.
|
||||
*
|
||||
* @param array $services Array of Service objects to filter
|
||||
* @param array $services Array of Service objects to filter
|
||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||
* @param int $participantIndex Index of the participant to evaluate
|
||||
* @param int $participantIndex Index of the participant to evaluate
|
||||
*
|
||||
* @return array Filtered array of available services
|
||||
*/
|
||||
@@ -667,7 +665,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
* Gets eligible insurances for a participant based on eligibility criteria.
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @return array Array of eligible insurance objects filtered by age, family status, and other constraints
|
||||
*/
|
||||
|
||||
@@ -125,6 +125,10 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
$currentInsurance = $participant->insurance;
|
||||
$availableInsurances = $bookingDto->travel->insurances ?? [];
|
||||
|
||||
// Exclude complementary insurances from reassignment logic
|
||||
// They are only available as part of packages and cannot be directly selected
|
||||
$availableInsurances = array_filter($availableInsurances, fn ($insurance) => !$insurance->complementary);
|
||||
|
||||
// Determine if this is a new user selection or just form resubmission
|
||||
$isNewSelection = null !== $selectedInsuranceId
|
||||
&& (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance));
|
||||
|
||||
Reference in New Issue
Block a user