fix: preserve selected services from booking even when unavailable

When editing a booking, services that were previously booked but are no
longer available in the travel catalog were being dropped. This caused
API error 650 ("Anzahl Leistung stimmt nicht mit Teilnehmerzuordnung
überein") because the service participant counts no longer matched.

The fix ensures that in edit mode, booked services are merged with
travel data services in both:
- Form rendering (ParticipantFieldOptionsProvider): so checkboxes appear
- Handler validation (AbstractParticipantFieldHandler): so selections
  are accepted

This allows users to keep their existing service selections or
deliberately replace them with other available options. Create mode
remains unchanged.
This commit is contained in:
Björn Fromme
2026-03-16 12:02:28 +01:00
parent 029a87c6ca
commit 957e20d763
16 changed files with 204 additions and 37 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
@@ -122,7 +123,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Returns empty array when no courses available
$this->fieldOptionProviders['courses'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_COURSES),
$bookingDto,
$participantIndex
);
@@ -166,7 +167,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Returns empty array when no additional services available
$this->fieldOptionProviders['additionalServices'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_ADDITIONAL),
$bookingDto,
$participantIndex
);
@@ -217,7 +218,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Returns empty array when no board options available
$this->fieldOptionProviders['board'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_BOARD),
$bookingDto,
$participantIndex
);
@@ -261,7 +262,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Returns empty array when no dietary options available
$this->fieldOptionProviders['veg'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_VEG, true),
$bookingDto,
$participantIndex
);
@@ -316,7 +317,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$this->fieldOptionProviders['rentals'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterServicesByAgeConstraints(
$this->filterRentalsBySkiPassDuration(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_RENTALS, true),
$bookingDto,
$participantIndex
),
@@ -386,7 +387,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Returns empty array when no skipass options available
$this->fieldOptionProviders['skiPass'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $this->filterSkiPassChoices(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
$this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_SKI_PASS, true),
$bookingDto,
$participantIndex
);
@@ -557,7 +558,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Simple checkbox since there's only ever one parking type
// Returns empty array when no parking services exist to prevent field from rendering
$this->fieldOptionProviders['parking'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING);
$parkingServices = $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_PARKING);
if (true === empty($parkingServices)) {
return [];
}
@@ -1255,4 +1256,65 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Fallback: return all services if we don't have the expected discount/regular pair
return [...$otherServices, ...$pkwServices];
}
/**
* Retrieves available services from travel data, merged with booked services in edit mode.
*
* In edit mode, services that were previously booked for the participant are included
* even if they're no longer available in travel data. This ensures form checkboxes
* appear for booked services, allowing users to keep or deselect them.
*
* @param BookingDto $bookingDto The booking DTO containing travel and booking data
* @param int $participantIndex The participant index to retrieve booked services for
* @param mixed $serviceGroup The service group(s) to filter by (e.g., TOKEN_RENTALS)
* @param bool $filterByTravelDateRange Whether to filter travel services by date range
*
* @return Service[] Array of available services keyed by service ID
*/
private function getServicesWithBooked(
BookingDto $bookingDto,
int $participantIndex,
mixed $serviceGroup,
bool $filterByTravelDateRange = false,
): array {
$availableServices = $bookingDto->travel->getAdditionalServicesBySubTypes($serviceGroup, $filterByTravelDateRange);
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) {
$availableServices = $this->mergeBookedServices(
$availableServices,
$bookingDto->booking,
$participantIndex,
$serviceGroup
);
}
return $availableServices;
}
/**
* Merges booked services into the available services array.
*
* @param Service[] $availableServices The services available from travel data
* @param Booking $booking The booking containing previously booked services
* @param int $participantIndex The participant index to retrieve booked services for
* @param mixed $serviceGroup The service group(s) to filter by
*
* @return Service[] Merged array of services keyed by service ID
*/
private function mergeBookedServices(
array $availableServices,
Booking $booking,
int $participantIndex,
mixed $serviceGroup,
): array {
$bookedServices = $booking->getAdditionalServicesForParticipantByGroup($participantIndex, $serviceGroup);
foreach ($bookedServices as $bookedService) {
if (false === isset($availableServices[$bookedService->id])) {
$availableServices[$bookedService->id] = $bookedService;
}
}
return $availableServices;
}
}