fix: properly assign readonly state to form fields for unavailable services

This commit is contained in:
Björn Fromme
2026-03-16 12:00:56 +01:00
parent 96619c6cec
commit 3984a623bf
14 changed files with 68 additions and 140 deletions
+17 -29
View File
@@ -114,31 +114,28 @@ class Travel
public array $insurances = [];
/**
* Retrieves additional services filtered by subtype, availability, and optionally by travel date range.
* Retrieves additional services filtered by subtype and optionally by travel date range.
*
* Filters additional services based on the provided subtype(s), availability,
* and optionally whether their date range overlaps with the travel dates.
* Services with null dates are considered always available when date filtering is enabled.
* Filters additional services based on the provided subtype(s) and optionally whether
* their date range overlaps with the travel dates. Services with null dates are
* considered always available when date filtering is enabled.
* Services are sorted by price in ascending order.
*
* Availability filtering is NOT applied here - it happens at the form/UI level where
* unavailable services are shown as readonly with appropriate tooltips.
*
* @param mixed $subTypes The service subtype(s) to filter by
* @param bool $filterAvailable Whether to include only available services
* @param bool $filterByTravelDateRange Whether to filter by travel date range overlap
*
* @return array<int, Service> The filtered and sorted services array
*/
public function getAdditionalServicesBySubTypes(mixed $subTypes, bool $filterAvailable = true, bool $filterByTravelDateRange = false): array
public function getAdditionalServicesBySubTypes(mixed $subTypes, bool $filterByTravelDateRange = false): array
{
$subTypes = (array) $subTypes;
$services = array_filter($this->additionalServices, function (Service $service) use ($subTypes, $filterAvailable, $filterByTravelDateRange) {
$services = array_filter($this->additionalServices, function (Service $service) use ($subTypes, $filterByTravelDateRange) {
// Check subtype
if (false === in_array($service->subType, $subTypes)) {
return false;
}
// Check availability
if (true === $filterAvailable && null !== $service->available && 0 >= $service->available) {
if (false === in_array($service->subType, $subTypes, true)) {
return false;
}
@@ -160,30 +157,21 @@ class Travel
}
/**
* Retrieves transportation services filtered by direction and availability.
* Retrieves transportation services filtered by direction.
*
* Filters transportation services based on travel direction and optionally
* by availability. Services are sorted by subtype.
*
* Note: PKW/CAR filtering based on booking context is NOT applied here.
* That filtering happens dynamically in ParticipantFieldOptionsProvider using
* per-booking availability calculations from ServiceAvailabilityCalculator.
* Filters transportation services based on travel direction. Services are sorted by subtype.
* Availability filtering is NOT applied here - it happens at the form/UI level where
* unavailable services are shown as readonly with appropriate tooltips.
*
* @param string $direction The travel direction to filter by
* @param bool $filterAvailable Whether to include only available services (API availability)
*
* @return array<int, Service> The filtered and sorted transportation services
*/
public function getTransportationServicesByDirection(string $direction, bool $filterAvailable = true): array
public function getTransportationServicesByDirection(string $direction): array
{
$services = array_filter($this->transportationServices, function (Service $service) use ($direction, $filterAvailable) {
return $direction === $service->direction
&& (false === $filterAvailable || $service->available > 0 || null === $service->available);
});
$services = array_filter($this->transportationServices, fn (Service $service) => $direction === $service->direction);
usort($services, function (Service $a, Service $b) {
return $a->subType <=> $b->subType;
});
usort($services, fn (Service $a, Service $b) => $a->subType <=> $b->subType);
return $services;
}
@@ -104,10 +104,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'expanded' => true,
'required' => false,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_COURSES,
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode
),
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
$bookingDto,
$participantIndex
),
@@ -142,10 +139,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'expanded' => true,
'required' => false,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_ADDITIONAL,
BookingDto::MODE_EDIT !== $bookingDto->getMode() // Only filter by availability in create mode
),
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
$bookingDto,
$participantIndex
),
@@ -188,10 +182,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'expanded' => true,
'required' => false,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_BOARD,
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode
),
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
$bookingDto,
$participantIndex
),
@@ -227,11 +218,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'required' => false,
'choices' => $this->filterServicesByAgeConstraints(
$this->filterRentalsBySkiPassDuration(
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_RENTALS,
BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode
true // Filter by travel date range
),
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true),
$bookingDto,
$participantIndex
),
@@ -264,10 +251,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Rental insurance field provider - provides rental insurance options when rental services are selected
$this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)),
'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true)),
'required' => false,
'property_path' => 'rentalInsuranceSelected',
'help' => $this->getRentalInsuranceDescription($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)),
'help' => $this->getRentalInsuranceDescription($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true)),
];
// License plate field provider - provides text input for vehicle license plate when parking is selected
@@ -292,11 +279,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'expanded' => true,
'required' => true,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_SKI_PASS,
BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode
true // Filter by travel date range
),
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
$bookingDto,
$participantIndex
),
@@ -340,7 +323,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Hinfahrt',
'choices' => $this->filterTransportationChoices(
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL, false),
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
$bookingDto,
$participantIndex
),
@@ -374,7 +357,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Inbound Transportation
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rückfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL, false),
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $service?->label,
'choice_value' => 'id',
'expanded' => true,
@@ -417,7 +400,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Parking (conditional - only shown when outbound transportation is PKW)
// Simple checkbox since there's only ever one parking type
$this->fieldOptionProviders['parking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)),
'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING)),
'required' => false,
];
@@ -118,7 +118,7 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
*/
private function findParkingService(BookingDto $bookingDto): ?Service
{
$parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true);
$parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING);
if (empty($parkingServices)) {
return null;
@@ -125,7 +125,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
*/
private function findRentalInsuranceService(BookingDto $bookingDto): ?Service
{
$rentalInsuranceServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true);
$rentalInsuranceServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true);
if (empty($rentalInsuranceServices)) {
return null;
@@ -83,7 +83,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
}
$selectedRentals = $this->getFieldValue($submittedData, $this->getFieldName()) ?? [];
$availableRentals = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true);
$availableRentals = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true);
// Filter rentals by skipass duration to ensure only matching rentals are available
$durationFilteredRentals = $this->filterRentalsBySkiPassDuration($availableRentals, $participant);
@@ -94,8 +94,8 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
// Extract current skipass selection from submitted data
$selectedSkiPass = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available skipasses from travel data (with date filtering)
$availableSkipasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true);
// Get all skipasses from travel data (with date filtering)
$availableSkipasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true);
// For single selection, validate the selected skipass and convert ID to Service object
$validSelection = null;
@@ -62,10 +62,8 @@ class ParticipantTransportationDiscountReplacementFieldHandler extends AbstractP
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType;
// Step 3: Get all outbound PKW services
// Get ALL outbound services (not filtered by availability or smart filtering)
$allOutboundServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::OUTBOUND_TRAVEL,
false // Do NOT filter by per-booking availability
DirectionMapper::OUTBOUND_TRAVEL
);
// Find discounted and regular PKW services
@@ -50,13 +50,12 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi
$selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available inbound transportation services
$availableServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::INBOUND_TRAVEL,
true // filter available
// Get all inbound transportation services (availability filtering happens at form level)
$services = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::INBOUND_TRAVEL
);
// Validate and update participant with selection
$participant->transportationInbound = $this->findItemById($selectedTransportation, $availableServices);
$participant->transportationInbound = $this->findItemById($selectedTransportation, $services);
}
}
@@ -50,13 +50,12 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF
$selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available outbound transportation services
$availableServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::OUTBOUND_TRAVEL,
true // filter available
// Get all outbound transportation services (availability filtering happens at form level)
$services = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::OUTBOUND_TRAVEL
);
// Validate and update participant with selection
$participant->transportationOutbound = $this->findItemById($selectedTransportation, $availableServices);
$participant->transportationOutbound = $this->findItemById($selectedTransportation, $services);
}
}
@@ -64,7 +64,7 @@ class ParticipantEligibilityService
return true;
}
$allSkiPasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true);
$allSkiPasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true);
$availableSkiPasses = array_filter(
$allSkiPasses,
fn (Service $service) => $this->isSkiPassAvailableForParticipant($service, $bookingDto, $participantIndex)
+22 -6
View File
@@ -63,12 +63,17 @@ class ServiceAvailabilityCalculator
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
return array_filter($services, function (Service $service) use ($remainingAvailability) {
// If service has no availability limit set, treat as unlimited
if (null === $service->available || $service->available <= 0) {
// If service has no availability limit set (null), treat as unlimited
if (null === $service->available) {
return true;
}
// For services with availability limits, check remaining availability
// If availability is 0, service is sold out at the API level
if (0 === $service->available) {
return false;
}
// For services with positive availability, check remaining availability
return ($remainingAvailability[$service->id] ?? $service->available) > 0;
});
}
@@ -94,11 +99,22 @@ class ServiceAvailabilityCalculator
}
}
// If service not found or has no availability limit, it's not unavailable
if (null === $service || null === $service->available || $service->available <= 0) {
// If service not found, treat as available (not unavailable)
if (null === $service) {
return false;
}
// If no availability tracking (null), service is unlimited and available
if (null === $service->available) {
return false;
}
// If availability is 0, service is sold out at the API level
if (0 === $service->available) {
return true;
}
// For services with positive availability, calculate remaining based on booking selections
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0;
@@ -195,7 +211,7 @@ class ServiceAvailabilityCalculator
{
$allServices = [];
// Get transportation services
// Get all transportation services
$transportationServices = array_merge(
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL) ?? [],
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL) ?? []
+1 -5
View File
@@ -61,11 +61,7 @@ class AppRuntime implements RuntimeExtensionInterface
*/
public function formatServicePrice(float|int|null $price): string
{
if (null === $price) {
return '';
}
if (0 === $price || 0.0 === $price) {
if (null === $price || 0 === $price || 0.0 === $price) {
return 'inkl.';
}
@@ -347,7 +347,7 @@
{% endfor %}
{% if form.rentalInsurance is defined %}
{% set rentalInsuranceService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE'), true, true)|first %}
{% set rentalInsuranceService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE'), true)|first %}
{{ macros.checkbox_row(
form.rentalInsurance,
constant('App\\BusProNet\\Constants::SERVICE_LABELS')[constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE')],
@@ -532,7 +532,7 @@
{% endfor %}
{% if form.parking is defined %}
{% set parkingService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_PARKING'), true)|first %}
{% set parkingService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_PARKING'))|first %}
{{ macros.checkbox_row(
form.parking,
'Parkplatz',
-51
View File
@@ -49,57 +49,6 @@ class TravelTest extends TestCase
$this->assertSame($inboundPkw, $inboundResult[0]);
}
/**
* Test that unavailable services are filtered out when filterAvailable is true.
*/
public function testUnavailableServicesFiltered(): void
{
$travel = new Travel();
$availablePkw = new Service();
$availablePkw->id = 1;
$availablePkw->subType = 'PKW';
$availablePkw->direction = 'HIN';
$availablePkw->label = 'Eigene Anreise';
$availablePkw->available = 10;
$unavailablePkw = new Service();
$unavailablePkw->id = 2;
$unavailablePkw->subType = 'PKW';
$unavailablePkw->direction = 'HIN';
$unavailablePkw->label = 'Eigene Anreise mit Rabatt';
$unavailablePkw->available = 0;
$travel->transportationServices = [$availablePkw, $unavailablePkw];
$result = $travel->getTransportationServicesByDirection('HIN', true);
$this->assertCount(1, $result);
$this->assertSame($availablePkw, $result[0]);
}
/**
* Test that services with null availability are included.
*/
public function testNullAvailabilityIncluded(): void
{
$travel = new Travel();
$pkwWithNullAvailability = new Service();
$pkwWithNullAvailability->id = 1;
$pkwWithNullAvailability->subType = 'PKW';
$pkwWithNullAvailability->direction = 'HIN';
$pkwWithNullAvailability->label = 'Eigene Anreise';
$pkwWithNullAvailability->available = null;
$travel->transportationServices = [$pkwWithNullAvailability];
$result = $travel->getTransportationServicesByDirection('HIN', true);
$this->assertCount(1, $result);
$this->assertSame($pkwWithNullAvailability, $result[0]);
}
/**
* Test that multiple PKW services are all returned (no smart filtering at Travel level).
*/