From 1949bf9b97699ab4bb712779600013c6af3ba158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 22 Oct 2025 15:53:54 +0200 Subject: [PATCH] feat: prefer discounted transportation services addresses #869axbjbz --- docs/PROJECT_OVERVIEW.md | 10 ++ src/BusProNet/Model/Travel.php | 8 +- .../ParticipantFieldOptionsProvider.php | 83 ++++++++- tests/BusProNet/Model/TravelTest.php | 163 ++++++++++++++++++ 4 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 tests/BusProNet/Model/TravelTest.php diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index d57ee03..c4c66bf 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -43,10 +43,18 @@ - **Field Handlers**: 15+ specialized handlers in `src/Form/Service/` - Registered via service tags with dependency resolution - Process in dependency order via `ParticipantFieldHandlerRegistry` + - `ParticipantDateOfBirthFieldHandler` handles array input from BirthdayType widget - **Conditional Fields**: Universal condition system (`FieldConditionInterface`) - Age-based, field-dependent, service-specific conditions - Applied via `CreateFieldStateProvider` / `EditFieldStateProvider` - **HTMX Integration**: Real-time updates for dynamic fields +- **Date of Birth Field**: Uses `BirthdayType` with three text inputs (day, month, year) + - Widget: `widget: 'text'` renders separate text fields instead of dropdowns + - Input format: `input: 'datetime_immutable'` for immutable date handling + - Custom theme: `birthday_widget` block in `templates/forms.html.twig` handles rendering + - German format: Day.Month.Year with hardcoded placeholders ("Tag", "Monat", "Jahr") + - Field order explicitly set to `['day', 'month', 'year']` for German date convention + - HTMX attributes applied to parent container for form refresh on change ### Service Layer (`src/Service/`) - `BookingService` - Core booking workflow @@ -213,6 +221,8 @@ Critical for correct pricing and auto-reassignment: - Courses, additional services, board, insurance hidden until DOB provided - Age evaluated at travel start date, not current date - Dual constraint types: absolute_age, birth_year, mixed +- **Array input handling**: `ParticipantDateOfBirthFieldHandler` processes array `['day' => X, 'month' => Y, 'year' => Z]` from three-field widget +- **Date normalization**: Handler converts array to `DateTimeImmutable` using `sprintf()` with proper formatting ### Bulk Insurance Booking - Applicant enables bulk → applies to all participants diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 2bf2688..0dfcfbb 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -48,7 +48,7 @@ class Travel /** * Travel booking status from BusProNet API. - * Possible values: 'Frei', 'Anfrage', 'Buchungsstop' + * Possible values: 'Frei', 'Anfrage', 'Buchungsstop'. */ #[Groups(['api:single'])] public ?string $status = null; @@ -153,8 +153,12 @@ class Travel * 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. + * * @param string $direction The travel direction to filter by - * @param bool $filterAvailable Whether to include only available services + * @param bool $filterAvailable Whether to include only available services (API availability) * * @return array The filtered and sorted transportation services */ diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index ede849a..7890ab6 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -349,7 +349,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Outbound Transportation $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Hinfahrt', - 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), + 'choices' => $this->filterTransportationChoices( + $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), + $bookingDto, + $participantIndex + ), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_value' => 'id', 'expanded' => true, @@ -767,4 +771,81 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $travelPrice ); } + + /** + * Filters transportation choices to show only one PKW option based on per-booking availability. + * + * When both discounted and regular self-organized (PKW/CAR) options exist: + * - If discounted option has remaining availability within this booking, show only discounted + * - If discounted option is sold out within this booking, show only regular + * - This creates dynamic fallback: as participants select/deselect, availability updates in real-time + * + * Business logic: Prefer showing discounted options while they have availability, but automatically + * fall back to regular options when discounts are exhausted. This allows participants who initially + * got the discount to change their mind (e.g., select bus instead), making the discount available + * for others in the same booking session. + * + * @param array $services Transportation services from Travel model + * @param BookingDto $bookingDto Current booking DTO with participant selections + * @param int $participantIndex Current participant being processed + * + * @return array Filtered transportation choices with smart PKW option selection + */ + private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array + { + // Only apply filtering in create mode + if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) { + return $services; + } + + // Separate PKW/CAR from other services (BUS, etc.) + $pkwServices = []; + $otherServices = []; + + foreach ($services as $service) { + if (in_array($service->subType, ['PKW', 'CAR'], true)) { + $pkwServices[] = $service; + } else { + $otherServices[] = $service; + } + } + + // If only one or no PKW service, no filtering needed + if (count($pkwServices) <= 1) { + return [...$otherServices, ...$pkwServices]; + } + + // Find discounted (negative price) and regular (zero/positive price) PKW options + $discountedPkw = null; + $regularPkw = null; + + foreach ($pkwServices as $pkw) { + if (null !== $pkw->price && $pkw->price < 0) { + $discountedPkw = $pkw; + } else { + $regularPkw = $pkw; + } + } + + // If we have both discounted and regular options, apply smart filtering + if (null !== $discountedPkw && null !== $regularPkw) { + // Check if discounted option is unavailable for this participant (per-booking availability) + $discountedIsUnavailable = $this->serviceAvailabilityCalculator->isServiceUnavailable( + $discountedPkw->id, + $bookingDto, + $participantIndex + ); + + if ($discountedIsUnavailable) { + // Discounted is sold out within this booking, show only regular + return [...$otherServices, $regularPkw]; + } else { + // Discounted has availability, show only discounted (hide regular) + return [...$otherServices, $discountedPkw]; + } + } + + // Fallback: return all services if we don't have the expected discount/regular pair + return [...$otherServices, ...$pkwServices]; + } } diff --git a/tests/BusProNet/Model/TravelTest.php b/tests/BusProNet/Model/TravelTest.php new file mode 100644 index 0000000..abb69a8 --- /dev/null +++ b/tests/BusProNet/Model/TravelTest.php @@ -0,0 +1,163 @@ +id = 1; + $outboundPkw->subType = 'PKW'; + $outboundPkw->direction = 'HIN'; + $outboundPkw->label = 'Eigene Anreise'; + $outboundPkw->available = 10; + + $inboundPkw = new Service(); + $inboundPkw->id = 2; + $inboundPkw->subType = 'PKW'; + $inboundPkw->direction = 'RUECK'; + $inboundPkw->label = 'Eigene Abreise'; + $inboundPkw->available = 10; + + $travel->transportationServices = [$outboundPkw, $inboundPkw]; + + $outboundResult = $travel->getTransportationServicesByDirection('HIN'); + $this->assertCount(1, $outboundResult); + $this->assertSame($outboundPkw, $outboundResult[0]); + + $inboundResult = $travel->getTransportationServicesByDirection('RUECK'); + $this->assertCount(1, $inboundResult); + $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). + */ + public function testMultiplePkwServicesAllReturned(): void + { + $travel = new Travel(); + + $regularPkw = new Service(); + $regularPkw->id = 1; + $regularPkw->subType = 'PKW'; + $regularPkw->direction = 'HIN'; + $regularPkw->label = 'Eigene Anreise'; + $regularPkw->price = 0.0; + $regularPkw->available = 96; + + $discountedPkw = new Service(); + $discountedPkw->id = 2; + $discountedPkw->subType = 'PKW'; + $discountedPkw->direction = 'HIN'; + $discountedPkw->label = 'Eigene Anreise mit Rabatt'; + $discountedPkw->price = -30.0; + $discountedPkw->available = 99; + + $travel->transportationServices = [$regularPkw, $discountedPkw]; + + $result = $travel->getTransportationServicesByDirection('HIN'); + + // Both PKW services should be returned (filtering happens at field options provider level) + $this->assertCount(2, $result); + } + + /** + * Test that services are sorted by subType. + */ + public function testServicesSortedBySubType(): void + { + $travel = new Travel(); + + $pkwService = new Service(); + $pkwService->id = 1; + $pkwService->subType = 'PKW'; + $pkwService->direction = 'HIN'; + $pkwService->available = 10; + + $busService = new Service(); + $busService->id = 2; + $busService->subType = 'BUS'; + $busService->direction = 'HIN'; + $busService->available = 95; + + // Add in reverse order to test sorting + $travel->transportationServices = [$pkwService, $busService]; + + $result = $travel->getTransportationServicesByDirection('HIN'); + + $this->assertCount(2, $result); + // BUS comes before PKW alphabetically + $this->assertSame('BUS', $result[0]->subType); + $this->assertSame('PKW', $result[1]->subType); + } +}