fix: stop offering unbookable services in the booking edit flow

This commit is contained in:
2026-09-16 15:28:07 +02:00
parent 672fc30d7e
commit 160ebef39e
18 changed files with 1094 additions and 104 deletions
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description;
}
// Make readonly if service is unavailable (intelligently handles edit mode)
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) {
// Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
}
// Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) {
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) {
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes;
}
// Make readonly if service is unavailable (intelligently handles edit mode)
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) {
// Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) {
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description;
}
// Make readonly if service is unavailable (intelligently handles edit mode)
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) {
// Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description;
}
// Make readonly if service is unavailable (intelligently handles edit mode)
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) {
// Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht';
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
}
return $attributes;
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$fieldOptions['attr']['data-description'] = $parkingService->description;
}
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) {
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
$fieldOptions['attr']['readonly'] = true;
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht';
$fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
}
return $fieldOptions;
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// For non-create workflows, don't apply availability restrictions
return false;
}
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
}
/**
* Determines if a service should be rendered as read-only.
*
* This method intelligently handles readonly state for services in both create and edit modes:
*
* - CREATE MODE: Uses existing availability calculator logic
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
*
* This prevents fingerprint false positives in edit mode by allowing participants to keep
* services they already have, even if those services are now fully booked.
* Both modes share one rule set. The already-held carve-out inside
* ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
* service that is now sold out or on request, so no mode-specific branch is needed here.
*
* @param Service $service The service to check
* @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant currently selecting services
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
*
* @return bool True if the service should be read-only
*/
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
// In CREATE mode, use existing availability logic
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
// In EDIT mode, apply intelligent readonly logic
// If service is available (available > 0), it's never readonly
if (null !== $service->available && $service->available > 0) {
return false;
}
// Service is unavailable - check if participant already has it
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return true; // Readonly if no participant data
}
// Check if participant has this service based on field type
$participantHasService = match ($fieldName) {
'courses' => $this->hasServiceById($participant->courses, $service->id),
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
'board' => $this->hasServiceById($participant->board, $service->id),
'veg' => $participant->veg?->id === $service->id,
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
'skiPass' => $participant->skiPass?->id === $service->id,
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
default => false,
};
// Make readonly only if participant doesn't have it
return false === $participantHasService;
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
/**
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return false;
}
/**
* Returns the tooltip explaining why a service is rendered read-only.
*
* Only called for a service that is already known to be blocked, so the status alone
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
* never fires there.
*/
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
{
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
&& Constants::STATUS_ON_REQUEST === $service->status) {
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
}
return 'ausgebucht';
}
/**
* Filters services based on participant's age constraints.
*