From b323c120f9cdff1e4a5eb0ebd6e808a7b707918d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 23 Jul 2026 17:44:08 +0200 Subject: [PATCH] fix: consider parking space availability --- config/services.yaml | 2 +- .../ParticipantFieldOptionsProvider.php | 13 ++- .../ParticipantParkingFieldHandler.php | 23 +++++- src/Service/ServiceAvailabilityCalculator.php | 6 ++ ...eldOptionsProviderMandatoryServiceTest.php | 58 ++++++++++++++ .../ParticipantParkingFieldHandlerTest.php | 59 +++++++++++++- .../ServiceAvailabilityCalculatorTest.php | 80 +++++++++++++++++++ 7 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 tests/Service/ServiceAvailabilityCalculatorTest.php diff --git a/config/services.yaml b/config/services.yaml index 80958e5..81c7f1b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -195,7 +195,6 @@ services: - 'App\Form\Service\ParticipantTransportationInboundFieldHandler' - 'App\Form\Service\ParticipantPickupFieldHandler' - 'App\Form\Service\ParticipantDropOffFieldHandler' - - 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler' # Complex handlers with dependencies - use service references @@ -205,6 +204,7 @@ services: - '@App\Form\Service\ParticipantInsuranceFieldHandler' - '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantPromoVoucherFieldHandler' + - '@App\Form\Service\ParticipantParkingFieldHandler' # Booking Status Rules App\BusProNet\Service\StatusRule\Selection1473StatusRule: ~ diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 9333431..dee416c 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -647,13 +647,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return []; } - return [ + $fieldOptions = [ 'label' => $this->serviceLabelFormatter->formatServiceLabelForServices( Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], $parkingServices ), 'required' => false, ]; + + // There's only ever one parking type, so check readonly state against the single service + $parkingService = reset($parkingServices); + if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) { + $fieldOptions['attr'] = [ + 'readonly' => true, + 'data-tooltip' => 'ausgebucht', + ]; + } + + return $fieldOptions; }; // Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants) diff --git a/src/Form/Service/ParticipantParkingFieldHandler.php b/src/Form/Service/ParticipantParkingFieldHandler.php index 11f05f3..d19c946 100644 --- a/src/Form/Service/ParticipantParkingFieldHandler.php +++ b/src/Form/Service/ParticipantParkingFieldHandler.php @@ -10,6 +10,7 @@ use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; +use App\Service\ServiceAvailabilityCalculator; /** * Handles parking service selection for self-organized transportation. @@ -20,6 +21,11 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler; */ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler { + public function __construct( + private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, + ) { + } + public function getFieldName(): string { return 'parking'; @@ -73,7 +79,22 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler // Store parking service object for pricing calculation if (true === $isParkingSelected) { - $participant->parkingService = $this->findParkingService($bookingDto, $participantIndex); + $parkingService = $this->findParkingService($bookingDto, $participantIndex); + + // Reject the selection if the service is sold out, in case the client + // bypassed the readonly UI state (e.g. a stale form or manual submission) + if ( + null !== $parkingService + && BookingDto::MODE_EDIT !== $bookingDto->getMode() + && $this->serviceAvailabilityCalculator->isServiceUnavailable($parkingService->id, $bookingDto, $participantIndex) + ) { + $participant->parking = false; + $participant->parkingService = null; + + return; + } + + $participant->parkingService = $parkingService; } else { $participant->parkingService = null; } diff --git a/src/Service/ServiceAvailabilityCalculator.php b/src/Service/ServiceAvailabilityCalculator.php index 1d8b931..4e931a9 100644 --- a/src/Service/ServiceAvailabilityCalculator.php +++ b/src/Service/ServiceAvailabilityCalculator.php @@ -173,6 +173,11 @@ class ServiceAvailabilityCalculator $serviceUsage[$participant->skiPass->id] = ($serviceUsage[$participant->skiPass->id] ?? 0) + 1; } + // Parking service (single selection) + if (null !== $participant->parkingService && null !== $participant->parkingService->id) { + $serviceUsage[$participant->parkingService->id] = ($serviceUsage[$participant->parkingService->id] ?? 0) + 1; + } + // Transportation services (single selection each) if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->id) { $serviceUsage[$participant->transportationOutbound->id] = ($serviceUsage[$participant->transportationOutbound->id] ?? 0) + 1; @@ -231,6 +236,7 @@ class ServiceAvailabilityCalculator Constants::TOKEN_RENTALS, Constants::TOKEN_SKI_PASS, Constants::TOKEN_BOARD, + Constants::TOKEN_PARKING, ]; foreach ($additionalServiceTokens as $token) { diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php index 6b7948e..f8e2db4 100644 --- a/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php +++ b/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php @@ -135,6 +135,64 @@ class ParticipantFieldOptionsProviderMandatoryServiceTest extends TestCase $this->assertSame('Parkplatz (€12,50)', $options['label']); } + public function testParkingIsReadonlyWhenSoldOut(): void + { + $parkingService = new Service(); + $parkingService->id = 3; + $parkingService->label = 'Parkplatz'; + $parkingService->subType = Constants::TOKEN_PARKING; + $parkingService->available = 0; + $parkingService->price = 12.5; + + $travel = $this->createTravelWithAdditionalServices([$parkingService]); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class); + $serviceAvailabilityCalculator->method('isServiceUnavailable') + ->with($parkingService->id, $bookingDto, 0) + ->willReturn(true); + + $provider = new ParticipantFieldOptionsProvider( + $serviceAvailabilityCalculator, + $this->createMock(InsuranceManager::class), + $this->createMock(BookingPriceCalculator::class), + new ServiceLabelFormatter(), + $this->createMock(TranslatorInterface::class) + ); + + $options = $provider->getFieldOptions('parking', $bookingDto, 0); + + $this->assertTrue($options['attr']['readonly']); + $this->assertSame('ausgebucht', $options['attr']['data-tooltip']); + } + + public function testParkingIsNotReadonlyWhenAvailable(): void + { + $parkingService = new Service(); + $parkingService->id = 3; + $parkingService->label = 'Parkplatz'; + $parkingService->subType = Constants::TOKEN_PARKING; + $parkingService->available = 10; + $parkingService->price = 12.5; + + $travel = $this->createTravelWithAdditionalServices([$parkingService]); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $options = $this->provider->getFieldOptions('parking', $bookingDto, 0); + + $this->assertArrayNotHasKey('attr', $options); + } + public function testNullParticipantReturnsEmptyOptions(): void { $mandatoryService = $this->createMandatoryService(1, 'Ortstaxe'); diff --git a/tests/Form/Service/ParticipantParkingFieldHandlerTest.php b/tests/Form/Service/ParticipantParkingFieldHandlerTest.php index 7568e65..589b255 100644 --- a/tests/Form/Service/ParticipantParkingFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantParkingFieldHandlerTest.php @@ -7,9 +7,11 @@ namespace App\Tests\Form\Service; use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DirectionMapper; +use App\BusProNet\Constants; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\ParticipantParkingFieldHandler; +use App\Service\ServiceAvailabilityCalculator; use PHPUnit\Framework\TestCase; class ParticipantParkingFieldHandlerTest extends TestCase @@ -18,7 +20,7 @@ class ParticipantParkingFieldHandlerTest extends TestCase protected function setUp(): void { - $this->handler = new ParticipantParkingFieldHandler(); + $this->handler = new ParticipantParkingFieldHandler(new ServiceAvailabilityCalculator()); } public function testGetFieldName(): void @@ -237,6 +239,50 @@ class ParticipantParkingFieldHandlerTest extends TestCase $this->assertFalse($participant->parking); } + public function testProcessFieldRejectsParkingWhenSoldOut(): void + { + $pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API); + $parkingService = $this->createParkingService(available: 0); + + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('-25 years'); + $participant->transportationOutbound = $pkwService; + + $travel = new Travel(); + $travel->additionalServices = [$parkingService->id => $parkingService]; + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $submittedData = ['parking' => true]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertFalse($participant->parking); + $this->assertNull($participant->parkingService); + } + + public function testProcessFieldAllowsParkingWhenAvailable(): void + { + $pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API); + $parkingService = $this->createParkingService(available: 5); + + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('-25 years'); + $participant->transportationOutbound = $pkwService; + + $travel = new Travel(); + $travel->additionalServices = [$parkingService->id => $parkingService]; + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $submittedData = ['parking' => true]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertTrue($participant->parking); + $this->assertSame($parkingService, $participant->parkingService); + } + public function testProcessFieldSetsParkingToFalseWhenNotSelected(): void { $pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API); @@ -300,4 +346,15 @@ class ParticipantParkingFieldHandlerTest extends TestCase return $service; } + + private function createParkingService(int $available): Service + { + $service = new Service(); + $service->id = 200; + $service->label = 'Parkplatz'; + $service->subType = Constants::TOKEN_PARKING; + $service->available = $available; + + return $service; + } } diff --git a/tests/Service/ServiceAvailabilityCalculatorTest.php b/tests/Service/ServiceAvailabilityCalculatorTest.php new file mode 100644 index 0000000..6b27c64 --- /dev/null +++ b/tests/Service/ServiceAvailabilityCalculatorTest.php @@ -0,0 +1,80 @@ +calculator = new ServiceAvailabilityCalculator(); + } + + public function testParkingServiceIsUnavailableWhenSoldOut(): void + { + $parkingService = $this->createParkingService(id: 1, available: 0); + $bookingDto = $this->createBookingDtoWithServices([$parkingService]); + + $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testParkingServiceIsAvailableWhenQuotaRemains(): void + { + $parkingService = $this->createParkingService(id: 1, available: 5); + $bookingDto = $this->createBookingDtoWithServices([$parkingService]); + + $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testParkingSelectedByOtherParticipantCountsTowardUsage(): void + { + $parkingService = $this->createParkingService(id: 1, available: 1); + $bookingDto = $this->createBookingDtoWithServices([$parkingService]); + + $otherParticipant = new ParticipantDto(); + $otherParticipant->parkingService = $parkingService; + $bookingDto->participants[1] = $otherParticipant; + + // Only one parking slot remains, and another participant already claimed it, + // so it must show unavailable for the current participant (index 0) + $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + private function createParkingService(int $id, int $available): Service + { + $service = new Service(); + $service->id = $id; + $service->label = 'Parkplatz'; + $service->subType = Constants::TOKEN_PARKING; + $service->available = $available; + + return $service; + } + + private function createBookingDtoWithServices(array $services): BookingDto + { + $travel = new Travel(); + + $indexed = []; + foreach ($services as $service) { + $indexed[$service->id] = $service; + } + $travel->additionalServices = $indexed; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants[0] = new ParticipantDto(); + + return $bookingDto; + } +}