From b41b19d4c63cedba758a5c608aca935b7c8f13c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 3 Jun 2026 17:41:35 +0200 Subject: [PATCH] feat: pre-flight check of agency bookings addresses #869bqxr4q --- .../DataProcessor/BookingDataProcessor.php | 6 +- src/BusProNet/Model/Address.php | 27 ++ .../Booking/Edit/IndexController.php | 29 +- .../Booking/Edit/ParticipantController.php | 84 +++- src/Form/BookingEditType.php | 14 +- src/Form/Model/BookingDto.php | 2 +- src/Form/Model/BookingEditContext.php | 4 +- src/Form/Model/ParticipantDto.php | 72 +++- src/Form/Model/ParticipantEditDto.php | 54 +-- src/Service/BookingEditContextFactory.php | 13 +- src/Service/BookingEditDraftMerger.php | 2 + src/Service/BookingEditPreFlightChecker.php | 150 +++++++ src/Service/ParticipantCardAssembler.php | 160 +++++--- src/Service/ParticipantDataPrefiller.php | 6 - templates/_partials/_alert.html.twig | 6 +- templates/booking/_participant_card.html.twig | 32 +- templates/booking/edit/index.html.twig | 25 +- .../Booking/Edit/IndexControllerTest.php | 382 +++++++++++++++++- .../Edit/ParticipantControllerTest.php | 259 ++++++++++++ tests/Form/BookingEditTypeTest.php | 52 +++ tests/Form/Model/BookingDtoTest.php | 41 ++ tests/Form/Model/ParticipantEditDtoTest.php | 93 +++++ .../Service/BookingEditContextFactoryTest.php | 168 ++------ .../BookingEditPreFlightCheckerTest.php | 341 ++++++++++++++++ .../Service/ParticipantCardAssemblerTest.php | 96 ++++- .../Service/ParticipantDataPrefillerTest.php | 4 +- 26 files changed, 1818 insertions(+), 304 deletions(-) create mode 100644 src/Service/BookingEditPreFlightChecker.php create mode 100644 tests/Controller/Booking/Edit/ParticipantControllerTest.php create mode 100644 tests/Form/BookingEditTypeTest.php create mode 100644 tests/Service/BookingEditPreFlightCheckerTest.php diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 2686e34..a669f53 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -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; } diff --git a/src/BusProNet/Model/Address.php b/src/BusProNet/Model/Address.php index 85bb5d3..6b4dbca 100644 --- a/src/BusProNet/Model/Address.php +++ b/src/BusProNet/Model/Address.php @@ -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; + } } diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index b50dc48..43e18a9 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -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 = [ diff --git a/src/Controller/Booking/Edit/ParticipantController.php b/src/Controller/Booking/Edit/ParticipantController.php index aef92b6..31809ea 100644 --- a/src/Controller/Booking/Edit/ParticipantController.php +++ b/src/Controller/Booking/Edit/ParticipantController.php @@ -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 + */ + 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 $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); diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index f4db55c..f0b87b0 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -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 */ 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']; }, ]); } diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 5ddd6fe..d3ba4cb 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -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; diff --git a/src/Form/Model/BookingEditContext.php b/src/Form/Model/BookingEditContext.php index fe8a5f9..42994da 100644 --- a/src/Form/Model/BookingEditContext.php +++ b/src/Form/Model/BookingEditContext.php @@ -13,6 +13,7 @@ class BookingEditContext { /** * @param array|null $cardsData + * @param array> $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, ) { } } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index e3e1b0f..6535a57 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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(); diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 8631b54..a384f19 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -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 diff --git a/src/Service/BookingEditContextFactory.php b/src/Service/BookingEditContextFactory.php index 1976a9e..f97ee21 100644 --- a/src/Service/BookingEditContextFactory.php +++ b/src/Service/BookingEditContextFactory.php @@ -45,22 +45,29 @@ class BookingEditContextFactory ); } + /** + * @param array> $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, ); } } diff --git a/src/Service/BookingEditDraftMerger.php b/src/Service/BookingEditDraftMerger.php index cdc2c5f..b0088be 100644 --- a/src/Service/BookingEditDraftMerger.php +++ b/src/Service/BookingEditDraftMerger.php @@ -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 diff --git a/src/Service/BookingEditPreFlightChecker.php b/src/Service/BookingEditPreFlightChecker.php new file mode 100644 index 0000000..cf58d03 --- /dev/null +++ b/src/Service/BookingEditPreFlightChecker.php @@ -0,0 +1,150 @@ +> + */ + 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 + */ + 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 + */ + 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)); + } +} diff --git a/src/Service/ParticipantCardAssembler.php b/src/Service/ParticipantCardAssembler.php index edc4a09..b6043c8 100644 --- a/src/Service/ParticipantCardAssembler.php +++ b/src/Service/ParticipantCardAssembler.php @@ -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|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|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 + */ + 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 + */ + 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 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 $validationGroups + * @param array|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 - */ - 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 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; - } } diff --git a/src/Service/ParticipantDataPrefiller.php b/src/Service/ParticipantDataPrefiller.php index 962c23a..a77af38 100644 --- a/src/Service/ParticipantDataPrefiller.php +++ b/src/Service/ParticipantDataPrefiller.php @@ -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; } diff --git a/templates/_partials/_alert.html.twig b/templates/_partials/_alert.html.twig index 428a910..56717e1 100644 --- a/templates/_partials/_alert.html.twig +++ b/templates/_partials/_alert.html.twig @@ -3,7 +3,7 @@ {% endif %}