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
+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