feat: pre-flight check of agency bookings
addresses #869bqxr4q
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user