fix: properly assign readonly state to form fields for unavailable services
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) ?? []
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user