feat: pre-flight check of agency bookings

addresses #869bqxr4q
This commit is contained in:
Björn Fromme
2026-07-10 14:33:09 +02:00
parent f9142e3080
commit b41b19d4c6
26 changed files with 1818 additions and 304 deletions
@@ -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;
}
+27
View File
@@ -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);
+6 -8
View File
@@ -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'];
},
]);
}
+1 -1
View File
@@ -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;
+3 -1
View File
@@ -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,
) {
}
}
+59 -13
View File
@@ -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();
+16 -38
View File
@@ -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
+10 -3
View File
@@ -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,
);
}
}
+2
View File
@@ -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
+150
View File
@@ -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));
}
}
+109 -51
View File
@@ -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;
}
}
-6
View File
@@ -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;
}