feat: pre-flight check of agency bookings
addresses #869bqxr4q
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Processes booking form data and converts it into BusProNet API payload format.
|
||||
@@ -78,7 +79,7 @@ class BookingDataProcessor
|
||||
// Copy address if first participant has no street (indicating empty/incomplete address)
|
||||
$isEmpty = null === $participantData->address
|
||||
|| null === $participantData->address->street
|
||||
|| '' === trim($participantData->address->street);
|
||||
|| u($participantData->address->street)->trim()->isEmpty();
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
@@ -155,6 +156,9 @@ class BookingDataProcessor
|
||||
// Enrich services with data from travel (especially prices)
|
||||
$this->enrichParticipantServicesFromTravel($participantData, $travel);
|
||||
|
||||
// Normalize loaded participant data so the edit flow sees canonical values.
|
||||
$participantData->normalizeLoadedData();
|
||||
|
||||
$dto->participants[$index] = $participantData;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Represents a physical address with street, postal code, city, and country information.
|
||||
@@ -29,6 +30,21 @@ class Address
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
* Normalizes legacy string data loaded from API/session sources.
|
||||
*
|
||||
* Trims whitespace and converts empty strings to null so load-time audits
|
||||
* and form hydration see canonical values.
|
||||
*/
|
||||
public function normalize(): void
|
||||
{
|
||||
$this->street = $this->normalizeNullableString($this->street);
|
||||
$this->postCode = $this->normalizeNullableString($this->postCode);
|
||||
$this->city = $this->normalizeNullableString($this->city);
|
||||
$this->district = $this->normalizeNullableString($this->district);
|
||||
$this->country = $this->normalizeNullableString($this->country);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the address to API payload format.
|
||||
*
|
||||
@@ -47,4 +63,15 @@ class Address
|
||||
'land' => $this->country,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeNullableString(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = u($value)->trim()->toString();
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Service\BookingChangeTracker;
|
||||
use App\Service\BookingEditContextFactory;
|
||||
use App\Service\BookingEditDataLoader;
|
||||
use App\Service\BookingEditDraftManager;
|
||||
use App\Service\BookingEditPreFlightChecker;
|
||||
use App\Service\BookingEditSubmitter;
|
||||
use App\Service\BookingSessionManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -42,6 +43,7 @@ class IndexController extends AbstractController
|
||||
private readonly BookingSessionManager $bookingSessionService,
|
||||
private readonly BookingChangeTracker $fingerprintService,
|
||||
private readonly BookingEditContextFactory $editContextFactory,
|
||||
private readonly BookingEditPreFlightChecker $preFlightChecker,
|
||||
private readonly BookingEditSubmitter $formSubmitter,
|
||||
) {
|
||||
}
|
||||
@@ -107,8 +109,28 @@ class IndexController extends AbstractController
|
||||
$form = $this->createForm(BookingEditType::class, $bookingDto);
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Pre-flight check is display-only: it helps users find participants that still need work
|
||||
// without turning the overview into a blocking validation state.
|
||||
$missingValueLabelsByParticipantIndex = $this->preFlightChecker
|
||||
->findMissingValueLabelsByParticipantIndex($bookingDto);
|
||||
|
||||
// Keep these states separate:
|
||||
// - $isOverviewSubmitted: the footer form was posted
|
||||
// - $isOverviewFormValid: Symfony accepted the posted data
|
||||
// - $hasBlockingValidationErrors: only non-internal bookings block submission
|
||||
$isOverviewSubmitted = $form->isSubmitted();
|
||||
$isOverviewFormValid = false;
|
||||
|
||||
if (true === $isOverviewSubmitted) {
|
||||
$isOverviewFormValid = $form->isValid();
|
||||
}
|
||||
|
||||
$hasBlockingValidationErrors = true === $isOverviewSubmitted
|
||||
&& false === $isOverviewFormValid
|
||||
&& false === $bookingDto->isInternalAgencyBooking();
|
||||
|
||||
// Handle form submission (clicking "Buchung aktualisieren")
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (true === $isOverviewSubmitted && (true === $isOverviewFormValid || true === $bookingDto->isInternalAgencyBooking())) {
|
||||
$submissionResult = $this->formSubmitter->handleSubmission($request, $bookingDto, $bookingId, $user);
|
||||
$this->applySubmissionResultFlashes($submissionResult);
|
||||
|
||||
@@ -119,8 +141,9 @@ class IndexController extends AbstractController
|
||||
$bookingDto,
|
||||
$bookingData,
|
||||
$this->fingerprintService->isDirty($bookingDto),
|
||||
$form->isSubmitted(),
|
||||
$form->isSubmitted() && false === $form->isValid(),
|
||||
$isOverviewSubmitted,
|
||||
$hasBlockingValidationErrors,
|
||||
$missingValueLabelsByParticipantIndex,
|
||||
);
|
||||
|
||||
$templateData = [
|
||||
|
||||
@@ -13,9 +13,11 @@ use App\Htmx\HxTrait;
|
||||
use App\Service\BookingEditContextFactory;
|
||||
use App\Service\BookingEditDataLoader;
|
||||
use App\Service\BookingEditDraftManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -33,6 +35,7 @@ class ParticipantController extends AbstractController
|
||||
private readonly BookingEditDraftManager $draftService,
|
||||
private readonly BookingEditContextFactory $editContextFactory,
|
||||
private readonly BookingSessionManager $bookingSessionService,
|
||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
) {
|
||||
}
|
||||
@@ -76,16 +79,26 @@ class ParticipantController extends AbstractController
|
||||
|
||||
$this->editContextFactory->prepareBookingDto($bookingDto);
|
||||
|
||||
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
|
||||
|
||||
$form = $this->createForm(
|
||||
BookingParticipantType::class,
|
||||
$wrapper,
|
||||
$this->participantFormSupportService->getParticipantFormOptions($bookingDto)
|
||||
);
|
||||
$form = $this->createParticipantForm($bookingDto, $index);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
$isDummyDataFill = $this->prepopulationService->isDummyDataFillRequested(
|
||||
$bookingDto->participants[$index],
|
||||
$bookingDto->getMode()
|
||||
);
|
||||
|
||||
if (true === $form->isSubmitted() && true === $isDummyDataFill) {
|
||||
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
||||
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
|
||||
|
||||
$form = $this->createParticipantForm($bookingDto, $index);
|
||||
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto, $bookingId, $bookingData);
|
||||
}
|
||||
|
||||
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
@@ -96,20 +109,7 @@ class ParticipantController extends AbstractController
|
||||
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
|
||||
}
|
||||
|
||||
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
|
||||
|
||||
return $this->render('booking/edit/participant.html.twig', [
|
||||
'form' => $form,
|
||||
'participantIndex' => $index,
|
||||
'bookingEditContext' => $context,
|
||||
'bookingDto' => $context->bookingDto,
|
||||
'summaryData' => $context->summaryData,
|
||||
'mutableData' => $context->mutableData,
|
||||
'refreshRouteName' => 'app_booking_edit_participant_refresh',
|
||||
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
|
||||
'cancelRouteName' => 'app_booking_edit',
|
||||
'cancelRouteParams' => ['bookingId' => $bookingId],
|
||||
]);
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto, $bookingId, $bookingData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +190,48 @@ class ParticipantController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<mixed>
|
||||
*/
|
||||
private function createParticipantForm(BookingDto $bookingDto, int $index): FormInterface
|
||||
{
|
||||
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
|
||||
|
||||
return $this->createForm(
|
||||
BookingParticipantType::class,
|
||||
$wrapper,
|
||||
$this->participantFormSupportService->getParticipantFormOptions($bookingDto)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Booking|null $bookingData
|
||||
* @param FormInterface<mixed> $form
|
||||
*/
|
||||
private function renderParticipantForm(
|
||||
FormInterface $form,
|
||||
int $index,
|
||||
BookingDto $bookingDto,
|
||||
int $bookingId,
|
||||
?Booking $bookingData,
|
||||
): Response
|
||||
{
|
||||
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
|
||||
|
||||
return $this->render('booking/edit/participant.html.twig', [
|
||||
'form' => $form,
|
||||
'participantIndex' => $index,
|
||||
'bookingEditContext' => $context,
|
||||
'bookingDto' => $context->bookingDto,
|
||||
'summaryData' => $context->summaryData,
|
||||
'mutableData' => $context->mutableData,
|
||||
'refreshRouteName' => 'app_booking_edit_participant_refresh',
|
||||
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
|
||||
'cancelRouteName' => 'app_booking_edit',
|
||||
'cancelRouteParams' => ['bookingId' => $bookingId],
|
||||
]);
|
||||
}
|
||||
|
||||
private function isParticipantCanceled(Booking $bookingData, int $index): bool
|
||||
{
|
||||
return Booking::STATUS_CANCELED === ($bookingData->participantsStatus[$index] ?? null);
|
||||
|
||||
@@ -14,8 +14,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
* Form type for edit booking validation.
|
||||
*
|
||||
* This form is used in the card-based UI where participants are edited individually.
|
||||
* This form validates the complete BookingDto before allowing updates,
|
||||
* ensuring all participants have valid and complete data.
|
||||
* It only applies blocking validation for non-internal-agency bookings. Internal-agency
|
||||
* bookings rely on the preflight overview warnings and can still be submitted while
|
||||
* participants are incomplete.
|
||||
*/
|
||||
/** @extends AbstractType<BookingDto> */
|
||||
class BookingEditType extends AbstractType
|
||||
@@ -34,14 +35,11 @@ class BookingEditType extends AbstractType
|
||||
/** @var BookingDto $data */
|
||||
$data = $form->getData();
|
||||
|
||||
$groups = ['booking_edit'];
|
||||
|
||||
// Strict validation in edit mode except for internal agency bookings
|
||||
if (false === $data->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
if ($data->isInternalAgencyBooking()) {
|
||||
return ['booking_edit'];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
return ['booking_edit', 'strict_required'];
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ class BookingDto
|
||||
|
||||
foreach ($this->participants as $participant) {
|
||||
// Temporary cleanup for older serialized participants restored from session.
|
||||
$participant->normalizeBodyDimensions();
|
||||
$participant->normalizeLoadedData();
|
||||
}
|
||||
|
||||
// Old sessions (before c373a989) still carry a full Travel object;
|
||||
|
||||
@@ -13,6 +13,7 @@ class BookingEditContext
|
||||
{
|
||||
/**
|
||||
* @param array<int, ParticipantCardDataDto>|null $cardsData
|
||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BookingDto $bookingDto,
|
||||
@@ -20,9 +21,10 @@ class BookingEditContext
|
||||
public readonly ?BookingMutabilityDto $mutableData,
|
||||
public readonly BookingSummaryDto $summaryData,
|
||||
public readonly ?array $cardsData = null,
|
||||
public readonly array $missingValueLabelsByParticipantIndex = [],
|
||||
public readonly bool $isDirty = false,
|
||||
public readonly bool $isSubmitted = false,
|
||||
public readonly bool $hasValidationErrors = false,
|
||||
public readonly bool $hasBlockingValidationErrors = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\BusProNet\Model\Service;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
||||
class ParticipantDto
|
||||
@@ -374,6 +375,37 @@ class ParticipantDto
|
||||
$this->shoeSize = $this->normalizeBodyDimensionValue($this->shoeSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes participant data loaded from API/session sources.
|
||||
*
|
||||
* This is deliberately limited to mechanical cleanup:
|
||||
* - trim whitespace
|
||||
* - convert empty strings to null
|
||||
* - normalize legacy body dimension values
|
||||
*
|
||||
* It does not fill business-required values.
|
||||
*/
|
||||
public function normalizeLoadedData(): void
|
||||
{
|
||||
$this->firstName = $this->normalizeNullableString($this->firstName);
|
||||
$this->lastName = $this->normalizeNullableString($this->lastName);
|
||||
$this->title = $this->normalizeNullableString($this->title);
|
||||
$this->gender = $this->normalizeNullableString($this->gender);
|
||||
$this->nationality = $this->normalizeNullableString($this->nationality);
|
||||
$this->email = $this->normalizeNullableString($this->email);
|
||||
$this->mobile = $this->normalizeNullableString($this->mobile);
|
||||
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
|
||||
$this->licensePlate = $this->normalizeNullableString($this->licensePlate);
|
||||
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
|
||||
$this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode);
|
||||
|
||||
if (null !== $this->address) {
|
||||
$this->address->normalize();
|
||||
}
|
||||
|
||||
$this->normalizeBodyDimensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the DTO from session/serialization data.
|
||||
*
|
||||
@@ -393,8 +425,7 @@ class ParticipantDto
|
||||
$this->address = new Address();
|
||||
}
|
||||
|
||||
// TODO: Remove after all legacy drafts/session snapshots with body-dimension strings have expired.
|
||||
$this->normalizeBodyDimensions();
|
||||
$this->normalizeLoadedData();
|
||||
}
|
||||
|
||||
private function normalizeBodyDimensionValue(mixed $value): ?string
|
||||
@@ -407,7 +438,7 @@ class ParticipantDto
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
$value = u($value)->trim()->toString();
|
||||
if ('' === $value || false === ctype_digit($value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -415,22 +446,37 @@ class ParticipantDto
|
||||
return (string) (int) $value;
|
||||
}
|
||||
|
||||
private function normalizeNullableString(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = u($value)->trim()->toString();
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the applicant (index 0) has a complete address.
|
||||
* Validates that the participant has a complete address when required.
|
||||
*
|
||||
* This callback only applies to the applicant participant. Address validation includes:
|
||||
* - Address object must exist
|
||||
* - All required address fields must be filled (street, postCode, city, country)
|
||||
* In regular create/edit submission flows this only applies to participant 0.
|
||||
* The edit overview preflight checks address fields separately for the applicant only.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Only validate applicant's address
|
||||
// Only validate applicant's address in regular create/edit submission flows.
|
||||
if (0 !== $this->index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Address object is required for applicant
|
||||
$this->validateAddressFields($context);
|
||||
}
|
||||
|
||||
private function validateAddressFields(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Address object is required for the validated participant
|
||||
if (null === $this->address) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address')
|
||||
@@ -440,25 +486,25 @@ class ParticipantDto
|
||||
}
|
||||
|
||||
// Validate address subfields
|
||||
if (null === $this->address->street || '' === trim($this->address->street)) {
|
||||
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.street')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->postCode || '' === trim($this->address->postCode)) {
|
||||
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.postCode')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->city || '' === trim($this->address->city)) {
|
||||
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.city')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->country || '' === trim($this->address->country)) {
|
||||
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.country')
|
||||
->addViolation();
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\BusProNet\Constants;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Wrapper DTO for editing a participant within a booking context.
|
||||
@@ -36,14 +37,14 @@ class ParticipantEditDto
|
||||
* (participants aged 0-2 years at travel date). Babies don't qualify for any
|
||||
* ski pass and the field is hidden for them.
|
||||
*
|
||||
* In edit mode, this validation is skipped as the ski pass is readonly.
|
||||
* In edit submissions, this validation is skipped as the ski pass is readonly.
|
||||
*
|
||||
* This validation only runs when strict_required group is active.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateSkiPassRequired(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Skip validation in edit mode - ski pass is readonly
|
||||
// Skip validation in edit submissions - ski pass is readonly there.
|
||||
if (BookingDto::MODE_EDIT === $this->bookingContext->getMode()) {
|
||||
return;
|
||||
}
|
||||
@@ -70,50 +71,27 @@ class ParticipantEditDto
|
||||
* the insurance field is hidden and will be automatically assigned by the bulk
|
||||
* insurance handler.
|
||||
*
|
||||
* In edit mode, this validation is skipped entirely because insurance data is
|
||||
* readonly and preserved as-is from the BPN API (the insurance field handler
|
||||
* does not process insurance in edit mode).
|
||||
* In edit submissions, this validation is skipped entirely because insurance
|
||||
* data is readonly and preserved as-is from the BPN API.
|
||||
*
|
||||
* This validation only runs when strict_required group is active.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateInsuranceRequired(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Skip validation in edit mode - insurance is readonly and preserved as-is
|
||||
// Skip validation in edit submissions - insurance is readonly and preserved as-is.
|
||||
if (BookingDto::MODE_EDIT === $this->bookingContext->getMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insurance is always required for applicant in create mode
|
||||
if (true === $this->participant->isApplicant()) {
|
||||
if (null === $this->participant->insurance) {
|
||||
$context->buildViolation('Bitte auswählen')
|
||||
->atPath('participant.insurance')
|
||||
->addViolation();
|
||||
if (false === $this->participant->isApplicant()) {
|
||||
$applicant = $this->bookingContext->getParticipant(0);
|
||||
|
||||
if (true === $applicant?->bulkInsuranceBooking) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For dependent participants, check if bulk insurance is active
|
||||
$applicant = $this->bookingContext->getParticipant(0);
|
||||
if (null === $applicant) {
|
||||
// Applicant not found - shouldn't happen, but validate insurance to be safe
|
||||
if (null === $this->participant->insurance) {
|
||||
$context->buildViolation('Bitte auswählen')
|
||||
->atPath('participant.insurance')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If bulk insurance booking is active, skip validation (field is hidden, will be auto-assigned)
|
||||
if (true === $applicant->bulkInsuranceBooking) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bulk insurance not active - insurance is required
|
||||
if (null === $this->participant->insurance) {
|
||||
$context->buildViolation('Bitte auswählen')
|
||||
->atPath('participant.insurance')
|
||||
@@ -138,7 +116,7 @@ class ParticipantEditDto
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for applicant (index 0) - applicant's email can be shared with dependents
|
||||
// In regular create/edit submission flows, the applicant can share an email with dependents.
|
||||
if (true === $this->participant->isApplicant()) {
|
||||
return;
|
||||
}
|
||||
@@ -149,12 +127,12 @@ class ParticipantEditDto
|
||||
}
|
||||
|
||||
// Skip if email is null or empty (handled by @Email and @NotBlank constraints)
|
||||
if (null === $this->participant->email || '' === trim($this->participant->email)) {
|
||||
if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize current participant's email for comparison
|
||||
$normalizedEmail = strtolower(trim($this->participant->email));
|
||||
$normalizedEmail = u($this->participant->email)->trim()->lower()->toString();
|
||||
|
||||
// Check against all adult participants in booking context
|
||||
foreach ($this->bookingContext->participants as $index => $otherParticipant) {
|
||||
@@ -169,12 +147,12 @@ class ParticipantEditDto
|
||||
}
|
||||
|
||||
// Skip null or empty emails
|
||||
if (null === $otherParticipant->email || '' === trim($otherParticipant->email)) {
|
||||
if (null === $otherParticipant->email || u($otherParticipant->email)->trim()->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare normalized emails
|
||||
$otherNormalizedEmail = strtolower(trim($otherParticipant->email));
|
||||
$otherNormalizedEmail = u($otherParticipant->email)->trim()->lower()->toString();
|
||||
|
||||
if ($normalizedEmail === $otherNormalizedEmail) {
|
||||
// Add violation to participant.email path
|
||||
|
||||
@@ -45,22 +45,29 @@ class BookingEditContextFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||
*/
|
||||
public function createOverviewContext(
|
||||
BookingDto $bookingDto,
|
||||
Booking $bookingData,
|
||||
bool $isDirty,
|
||||
bool $isSubmitted,
|
||||
bool $hasValidationErrors,
|
||||
bool $hasBlockingValidationErrors,
|
||||
array $missingValueLabelsByParticipantIndex = [],
|
||||
): BookingEditContext {
|
||||
$cardsData = $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto);
|
||||
|
||||
return new BookingEditContext(
|
||||
bookingDto: $bookingDto,
|
||||
bookingData: $bookingData,
|
||||
mutableData: BookingMutabilityDto::fromBaseData($this->travelDataService->getMutabilityData($bookingData->dateId)),
|
||||
summaryData: $this->createSummaryData($bookingDto),
|
||||
cardsData: $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto),
|
||||
cardsData: $cardsData,
|
||||
missingValueLabelsByParticipantIndex: $missingValueLabelsByParticipantIndex,
|
||||
isDirty: $isDirty,
|
||||
isSubmitted: $isSubmitted,
|
||||
hasValidationErrors: $hasValidationErrors,
|
||||
hasBlockingValidationErrors: $hasBlockingValidationErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ class BookingEditDraftMerger
|
||||
if (isset($data['services']) && true === is_array($data['services'])) {
|
||||
$this->applyServiceSelections($participant, $data['services'], $travel);
|
||||
}
|
||||
|
||||
$participant->normalizeLoadedData();
|
||||
}
|
||||
|
||||
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\ParticipantEditDto;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Detects edit-overview participants that still need user attention.
|
||||
*
|
||||
* This is display-only: it highlights participants that still need work
|
||||
* without affecting whether the edit submit is allowed.
|
||||
*
|
||||
* The scan uses the participant edit wrapper so participant-scoped validation
|
||||
* follows the same booking-context rules as the participant form. Body
|
||||
* dimensions are optional; only provided values are checked against the range
|
||||
* validator unless a selection-based exemption applies.
|
||||
*/
|
||||
class BookingEditPreFlightChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ValidatorInterface $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display-only attention scan for the edit overview.
|
||||
*
|
||||
* Canceled participants are ignored because they are not actionable.
|
||||
*
|
||||
* @return array<int, list<string>>
|
||||
*/
|
||||
public function findMissingValueLabelsByParticipantIndex(BookingDto $bookingDto): array
|
||||
{
|
||||
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$missingValueLabelsByParticipantIndex = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
if (true === $participant->isCanceled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingValueLabels = $this->getMissingValueLabelsForParticipant($bookingDto, $participant);
|
||||
|
||||
if ([] !== $missingValueLabels) {
|
||||
$missingValueLabelsByParticipantIndex[$index] = $missingValueLabels;
|
||||
}
|
||||
}
|
||||
|
||||
return $missingValueLabelsByParticipantIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getMissingValueLabelsForParticipant(BookingDto $bookingDto, ParticipantDto $participant): array
|
||||
{
|
||||
$participantForAudit = new ParticipantEditDto(
|
||||
participant: $participant,
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
$violations = $this->validator->validate(
|
||||
$participantForAudit,
|
||||
null,
|
||||
['booking_edit', 'strict_required']
|
||||
);
|
||||
|
||||
$valueLabels = [];
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$valueLabel = $this->mapViolationToValueLabel($violation);
|
||||
if (null !== $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $bookingDto->isInternalAgencyBooking() && true === $participant->isApplicant()) {
|
||||
foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($valueLabels));
|
||||
}
|
||||
|
||||
private function mapViolationToValueLabel(ConstraintViolationInterface $violation): ?string
|
||||
{
|
||||
$propertyPath = $violation->getPropertyPath();
|
||||
|
||||
if (str_starts_with($propertyPath, 'participant.')) {
|
||||
$propertyPath = substr($propertyPath, strlen('participant.'));
|
||||
}
|
||||
|
||||
return match ($propertyPath) {
|
||||
'firstName' => 'Vorname',
|
||||
'lastName' => 'Nachname',
|
||||
'gender' => 'Anrede',
|
||||
'nationality' => 'Nationalität',
|
||||
'dateOfBirth' => 'Geburtsdatum',
|
||||
'email' => 'E-Mail',
|
||||
'height' => 'Körpergröße',
|
||||
'weight' => 'Gewicht',
|
||||
'shoeSize' => 'Schuhgröße',
|
||||
'assignedRoomId' => 'Zimmer',
|
||||
'transportationOutbound' => 'Anreise',
|
||||
'pickup' => 'Zustieg',
|
||||
'transportationInbound' => 'Abreise',
|
||||
'dropOff' => 'Ausstieg',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getMissingValueAddressLabels(ParticipantDto $participant): array
|
||||
{
|
||||
if (null === $participant->address) {
|
||||
return ['Adresse'];
|
||||
}
|
||||
|
||||
$valueLabels = [];
|
||||
$violations = $this->validator->validate($participant->address, null, ['personal_data']);
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$valueLabel = match ($violation->getPropertyPath()) {
|
||||
'street' => 'Straße',
|
||||
'postCode' => 'PLZ',
|
||||
'city' => 'Ort',
|
||||
'country' => 'Land',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($valueLabels));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ class ParticipantCardAssembler
|
||||
* Get card data for a single participant.
|
||||
*/
|
||||
public function getCardData(BookingDto $bookingDto, int $index): ParticipantCardDataDto
|
||||
{
|
||||
return $this->buildCardData($bookingDto, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function buildCardData(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
|
||||
@@ -45,7 +57,7 @@ class ParticipantCardAssembler
|
||||
$roomName = $this->getRoomName($bookingDto, $participant);
|
||||
|
||||
// Calculate pricing state for display
|
||||
$priceData = $this->getPriceData($bookingDto, $index);
|
||||
$priceData = $this->getPriceData($bookingDto, $index, $precomputedPrices);
|
||||
|
||||
// Check if participant is canceled
|
||||
$isCanceled = $participant->isCanceled();
|
||||
@@ -67,9 +79,10 @@ class ParticipantCardAssembler
|
||||
public function getAllCardsData(BookingDto $bookingDto): array
|
||||
{
|
||||
$cardsData = [];
|
||||
$prices = $this->calculateBulkParticipantPrices($bookingDto);
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardData($bookingDto, $index);
|
||||
$cardsData[$index] = $this->buildCardData($bookingDto, $index, $prices);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
@@ -122,8 +135,14 @@ class ParticipantCardAssembler
|
||||
*
|
||||
* Returns a dash marker when the price is zero and no room is assigned,
|
||||
* indicating incomplete configuration rather than a zero-cost booking.
|
||||
*
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function getPriceData(BookingDto $bookingDto, int $index): ParticipantCardPriceDto
|
||||
private function getPriceData(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardPriceDto
|
||||
{
|
||||
// Check if canceled (only possible in edit mode when booking property is set)
|
||||
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
|
||||
@@ -150,7 +169,7 @@ class ParticipantCardAssembler
|
||||
}
|
||||
|
||||
// For active participants: existing price calculation logic
|
||||
$prices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
$prices = $precomputedPrices ?? $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
|
||||
$price = $prices[$index] ?? 0.0;
|
||||
|
||||
@@ -166,8 +185,92 @@ class ParticipantCardAssembler
|
||||
/**
|
||||
* Get card data for a single participant with validation state.
|
||||
*/
|
||||
public function getCardDataWithValidation(BookingDto $bookingDto, int $index): ParticipantCardDataDto
|
||||
public function getCardDataWithValidation(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
bool $forceStrictRequired = false,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
return $this->getCardDataWithValidationInternal(
|
||||
$bookingDto,
|
||||
$index,
|
||||
$this->determineValidationGroups($bookingDto, $forceStrictRequired)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get card data for all participants with validation state.
|
||||
*
|
||||
* @return array<int, ParticipantCardDataDto>
|
||||
*/
|
||||
public function getAllCardsDataWithValidation(BookingDto $bookingDto, bool $forceStrictRequired = false): array
|
||||
{
|
||||
$cardsData = [];
|
||||
$prices = $this->calculateBulkParticipantPrices($bookingDto);
|
||||
$validationGroups = $this->determineValidationGroups($bookingDto, $forceStrictRequired);
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardDataWithValidationInternal(
|
||||
$bookingDto,
|
||||
$index,
|
||||
$validationGroups,
|
||||
$prices
|
||||
);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function calculateBulkParticipantPrices(BookingDto $bookingDto): array
|
||||
{
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
if (($bookingDto->booking?->participantsStatus[$index] ?? null) !== 'S') {
|
||||
return $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines validation groups based on booking mode and applicant mutability.
|
||||
*
|
||||
* @return array<string> Validation groups to apply
|
||||
*/
|
||||
private function determineValidationGroups(BookingDto $bookingDto, bool $forceStrictRequired = false): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
// Add mode-specific group
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
$groups[] = 'booking_create';
|
||||
$groups[] = 'strict_required';
|
||||
} else {
|
||||
$groups[] = 'booking_edit';
|
||||
|
||||
// Strict validation in edit mode except for internal agency bookings,
|
||||
// unless the caller explicitly asks for the full validation set.
|
||||
if (true === $forceStrictRequired || false === $bookingDto->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $validationGroups
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function getCardDataWithValidationInternal(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
array $validationGroups,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardDataDto {
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
|
||||
if (null === $participant) {
|
||||
@@ -175,17 +278,13 @@ class ParticipantCardAssembler
|
||||
}
|
||||
|
||||
// Get basic card data
|
||||
$cardData = $this->getCardData($bookingDto, $index);
|
||||
$cardData = $this->buildCardData($bookingDto, $index, $precomputedPrices);
|
||||
|
||||
// Wrap participant for validation
|
||||
$wrapper = new ParticipantEditDto(
|
||||
participant: $participant,
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
// Determine validation groups based on mode and mutability
|
||||
$validationGroups = $this->determineValidationGroups($bookingDto);
|
||||
|
||||
// Validate the wrapper DTO
|
||||
$violations = $this->validator->validate($wrapper, null, $validationGroups);
|
||||
|
||||
@@ -199,45 +298,4 @@ class ParticipantCardAssembler
|
||||
|
||||
return $cardData->withValidation($isValid, $errorMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get card data for all participants with validation state.
|
||||
*
|
||||
* @return array<int, ParticipantCardDataDto>
|
||||
*/
|
||||
public function getAllCardsDataWithValidation(BookingDto $bookingDto): array
|
||||
{
|
||||
$cardsData = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines validation groups based on booking mode and applicant mutability.
|
||||
*
|
||||
* @return array<string> Validation groups to apply
|
||||
*/
|
||||
private function determineValidationGroups(BookingDto $bookingDto): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
// Add mode-specific group
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
$groups[] = 'booking_create';
|
||||
$groups[] = 'strict_required';
|
||||
} else {
|
||||
$groups[] = 'booking_edit';
|
||||
|
||||
// Strict validation in edit mode except for internal agency bookings
|
||||
if (false === $bookingDto->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,15 +112,9 @@ class ParticipantDataPrefiller
|
||||
|
||||
/**
|
||||
* Checks if the participant's last name triggers dummy data fill.
|
||||
*
|
||||
* Only matches in create mode to avoid accidental triggering during edits.
|
||||
*/
|
||||
public function isDummyDataFillRequested(ParticipantDto $participant, string $mode): bool
|
||||
{
|
||||
if (BookingDto::MODE_CREATE !== $mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::TOKEN === $participant->lastName;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user