From cb56a4fe46e3283487f8344c958d6592ffd84cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 12 Mar 2026 16:18:17 +0100 Subject: [PATCH] feat: implement mutability cutoff for rentals addresses #869cca42x --- .../TravelStartCutoffReachedCondition.php | 57 +++++++++++++ src/Form/Service/CreateFieldStateProvider.php | 9 ++- src/Form/Service/EditFieldStateProvider.php | 13 ++- src/Service/BookingEditSubmitGuardService.php | 31 +++++-- src/Twig/AppExtension.php | 1 + src/Twig/AppRuntime.php | 18 +++++ templates/booking/_participant_form.html.twig | 8 ++ .../TravelStartCutoffReachedConditionTest.php | 81 +++++++++++++++++++ .../BookingEditSubmitGuardServiceTest.php | 43 ++++++++++ 9 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/Form/Service/Condition/TravelStartCutoffReachedCondition.php create mode 100644 tests/Form/Service/Condition/TravelStartCutoffReachedConditionTest.php diff --git a/src/Form/Service/Condition/TravelStartCutoffReachedCondition.php b/src/Form/Service/Condition/TravelStartCutoffReachedCondition.php new file mode 100644 index 0000000..8ba9b43 --- /dev/null +++ b/src/Form/Service/Condition/TravelStartCutoffReachedCondition.php @@ -0,0 +1,57 @@ += travelStart(startOfDay) - N days. + */ +class TravelStartCutoffReachedCondition implements FieldConditionInterface +{ + public const int DEFAULT_DAYS_BEFORE_START = 4; + + /** + * @param int $daysBeforeStart Number of days before travel start when the cutoff is reached + */ + public function __construct( + private readonly int $daysBeforeStart, + ) { + if ($daysBeforeStart < 0) { + throw new \InvalidArgumentException('daysBeforeStart must be greater than or equal to 0.'); + } + } + + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + if (null === $bookingDto->travel->dateFrom) { + return false; + } + + $travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom)->startOfDay(); + $cutoffDate = $travelStartDate->subDays($this->daysBeforeStart); + $today = CarbonImmutable::now()->startOfDay(); + + return $today->greaterThanOrEqualTo($cutoffDate); + } + + public function getDependentFields(): array + { + return []; + } + + public function getDescription(): string + { + return sprintf( + 'Cutoff reached from %d day(s) before travel start (inclusive)', + $this->daysBeforeStart + ); + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 93bbdd8..13b8269 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -23,6 +23,7 @@ use App\Form\Service\Condition\RoomSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; use App\Form\Service\Condition\SingleRoomTypeCondition; use App\Form\Service\Condition\SkiPassSelectionCondition; +use App\Form\Service\Condition\TravelStartCutoffReachedCondition; use App\Service\ParticipantEligibilityService; /** @@ -79,6 +80,10 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $rentalCondition = new RentalSelectionCondition(); $skiPassCondition = new SkiPassSelectionCondition(); + // Booking-level D-4 cutoff used to lock mutable service selections close to departure. + $serviceCutoffReachedCondition = new TravelStartCutoffReachedCondition( + TravelStartCutoffReachedCondition::DEFAULT_DAYS_BEFORE_START + ); // First participant read-only condition // Render personal data fields as static text for first participant in non-internal agency bookings @@ -177,13 +182,15 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ), ]; - // Show rentals only when both date of birth is provided AND skipass is selected AND participant is eligible + // Show rentals only when both date of birth is provided AND skipass is selected AND participant is eligible. + // From D-4 onward, keep rentals visible but make them read-only. $this->fieldStateConditions['rentals'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), CompositeCondition::not($skiPassCondition), $bookingEligibilityCondition ), + 'readonly' => $serviceCutoffReachedCondition, ]; $this->fieldStateConditions['board'] = [ diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 1001b6b..926ceb8 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -22,6 +22,7 @@ use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RoomSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; use App\Form\Service\Condition\SkiPassSelectionCondition; +use App\Form\Service\Condition\TravelStartCutoffReachedCondition; use App\Form\Service\Condition\TransportationServicesMutabilityCondition; /** @@ -48,6 +49,10 @@ class EditFieldStateProvider extends AbstractFieldStateProvider $additionalServicesMutabilityCondition = new AdditionalServicesMutabilityCondition(); $transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition(); $pickupsMutabilityCondition = new PickupsMutabilityCondition(); + // Booking-level D-4 cutoff used in addition to BPN mutability flags. + $serviceCutoffReachedCondition = new TravelStartCutoffReachedCondition( + TravelStartCutoffReachedCondition::DEFAULT_DAYS_BEFORE_START + ); // Personal data protection in edit mode: // 1. First participant in non-internal agency booking - always read-only (first participant = applicant) @@ -155,14 +160,18 @@ class EditFieldStateProvider extends AbstractFieldStateProvider 'readonly' => $additionalServicesMutabilityCondition, ]; - // Rentals - shown only when skipass selected, readonly if services not mutable + // Rentals - shown only when skipass selected. + // Read-only when either BPN additional services are immutable OR the D-4 cutoff is reached. // For first participant: only check skipass, not DOB $this->fieldStateConditions['rentals'] = [ 'hidden' => CompositeCondition::or( $hideUntilDobCondition, CompositeCondition::not($skiPassCondition) ), - 'readonly' => $additionalServicesMutabilityCondition, + 'readonly' => CompositeCondition::or( + $additionalServicesMutabilityCondition, + $serviceCutoffReachedCondition + ), ]; // Rental insurance - shown only when rentals selected AND LVS services exist, readonly if services not mutable diff --git a/src/Service/BookingEditSubmitGuardService.php b/src/Service/BookingEditSubmitGuardService.php index 4987e5a..a52b133 100644 --- a/src/Service/BookingEditSubmitGuardService.php +++ b/src/Service/BookingEditSubmitGuardService.php @@ -10,6 +10,7 @@ use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Form\Service\Condition\TravelStartCutoffReachedCondition; /** * Enforces immutable edit categories before submitting booking updates. @@ -34,6 +35,14 @@ class BookingEditSubmitGuardService public function reconcileImmutableCategories(BookingDto $workingDto, Booking $freshBooking): bool { $baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel); + // Booking-level D-4 cutoff used as submit-time safety net for rentals. + $serviceCutoffReachedCondition = new TravelStartCutoffReachedCondition( + TravelStartCutoffReachedCondition::DEFAULT_DAYS_BEFORE_START + ); + // TravelStartCutoffReachedCondition is booking-level (travel date only). + // The interface requires a participant index, but this condition does not use it, + // so we pass 0 as a neutral placeholder. + $rentalsLockedByCutoff = $serviceCutoffReachedCondition->evaluate($workingDto, 0, []); $changed = false; @@ -47,6 +56,12 @@ class BookingEditSubmitGuardService $changed = $this->reconcileAdditionalServices($participant, $baseline) || $changed; } + // Reconcile rentals either when BPN additional services are immutable + // or when our D-4 rule has been reached. + if (false === $workingDto->travel->additionalServicesMutable || $rentalsLockedByCutoff) { + $changed = $this->reconcileRentals($participant, $baseline) || $changed; + } + if (false === $workingDto->travel->transportationServicesMutable) { $changed = $this->reconcileTransportationServices($participant, $baseline) || $changed; } @@ -78,11 +93,6 @@ class BookingEditSubmitGuardService $changed = true; } - if (false === $this->areServiceListsEqual($participant->rentals, $baseline->rentals)) { - $participant->rentals = $baseline->rentals; - $changed = true; - } - if (false === $this->isSameService($participant->skiPass, $baseline->skiPass)) { $participant->skiPass = $baseline->skiPass; $changed = true; @@ -101,6 +111,17 @@ class BookingEditSubmitGuardService return $changed; } + private function reconcileRentals(ParticipantDto $participant, ParticipantDto $baseline): bool + { + if (false === $this->areServiceListsEqual($participant->rentals, $baseline->rentals)) { + $participant->rentals = $baseline->rentals; + + return true; + } + + return false; + } + private function reconcileTransportationServices(ParticipantDto $participant, ParticipantDto $baseline): bool { $changed = false; diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 272f377..5fa3f75 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -38,6 +38,7 @@ class AppExtension extends AbstractExtension new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]), new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']), new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']), + new TwigFunction('is_travel_start_cutoff_reached', [AppRuntime::class, 'isTravelStartCutoffReached']), new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']), new TwigFunction('booking_theme', [AppRuntime::class, 'getBookingTheme']), new TwigFunction('gtm_id', [AppRuntime::class, 'getGtmId']), diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 7da302f..73e4c40 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -7,6 +7,7 @@ namespace App\Twig; use App\BusProNet\DataProvider\CountryDataProvider; use App\EventListener\DomainThemeListener; use App\Form\Model\BookingDto; +use App\Form\Service\Condition\TravelStartCutoffReachedCondition; use App\Form\Service\CreateFieldStateProvider; use App\Form\Service\EditFieldStateProvider; use App\Model\DomainConfig; @@ -232,6 +233,23 @@ class AppRuntime implements RuntimeExtensionInterface return $labels; } + /** + * Checks whether a travel start cutoff window has been reached. + * + * Uses inclusive day semantics: cutoff is reached when current day is on or + * after (travel start day - $daysBeforeStart). + */ + public function isTravelStartCutoffReached(BookingDto $bookingDto, int $daysBeforeStart = 4): bool + { + try { + $condition = new TravelStartCutoffReachedCondition($daysBeforeStart); + } catch (\InvalidArgumentException) { + return false; + } + + return $condition->evaluate($bookingDto, 0, []); + } + /** * Returns the current booking theme from request attribute. * diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 9baa711..b2a7e2d 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -417,6 +417,14 @@ {% if form.rentals is defined %} {{ form_errors(form.rentals) }} {{ form_help(form.rentals) }} + {% if is_travel_start_cutoff_reached(bookingDto) %} +
+ + + + Online nicht mehr buchbar. Bitte direkt über die Hausleitung vor Ort anfragen (vorbehaltlich Verfügbarkeit) +
+ {% endif %} {% endif %} diff --git a/tests/Form/Service/Condition/TravelStartCutoffReachedConditionTest.php b/tests/Form/Service/Condition/TravelStartCutoffReachedConditionTest.php new file mode 100644 index 0000000..7f5a5b8 --- /dev/null +++ b/tests/Form/Service/Condition/TravelStartCutoffReachedConditionTest.php @@ -0,0 +1,81 @@ +evaluate($this->createCreateDto('2026-01-10 12:00:00'), 0, []); + + $this->assertFalse($result); + } + + public function testEvaluateReturnsTrueAtInclusiveCutoffBoundary(): void + { + CarbonImmutable::setTestNow('2026-01-06 01:00:00'); + $condition = new TravelStartCutoffReachedCondition(4); + + $result = $condition->evaluate($this->createCreateDto('2026-01-10 12:00:00'), 0, []); + + $this->assertTrue($result); + } + + public function testEvaluateReturnsTrueAfterCutoff(): void + { + CarbonImmutable::setTestNow('2026-01-08 09:00:00'); + $condition = new TravelStartCutoffReachedCondition(4); + + $result = $condition->evaluate($this->createCreateDto('2026-01-10 12:00:00'), 0, []); + + $this->assertTrue($result); + } + + public function testEvaluateReturnsTrueInEditModeAfterCutoff(): void + { + CarbonImmutable::setTestNow('2026-01-08 09:00:00'); + $condition = new TravelStartCutoffReachedCondition(4); + + $this->assertTrue($condition->evaluate($this->createEditDto('2026-01-10 12:00:00'), 0, [])); + } + + public function testConstructorThrowsForNegativeDays(): void + { + $this->expectException(\InvalidArgumentException::class); + + new TravelStartCutoffReachedCondition(-1); + } + + private function createCreateDto(string $travelStartDate): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable($travelStartDate); + + return new BookingDto($travel, 1); + } + + private function createEditDto(string $travelStartDate): BookingDto + { + $dto = $this->createCreateDto($travelStartDate); + $dto->booking = new Booking(); + + return $dto; + } +} diff --git a/tests/Service/BookingEditSubmitGuardServiceTest.php b/tests/Service/BookingEditSubmitGuardServiceTest.php index 464614e..47577a4 100644 --- a/tests/Service/BookingEditSubmitGuardServiceTest.php +++ b/tests/Service/BookingEditSubmitGuardServiceTest.php @@ -12,10 +12,16 @@ use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\BookingEditSubmitGuardService; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; class BookingEditSubmitGuardServiceTest extends TestCase { + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + } + public function testReconcileImmutableCategoriesRestoresLockedServiceData(): void { $travel = new Travel(); @@ -90,6 +96,43 @@ class BookingEditSubmitGuardServiceTest extends TestCase $this->assertSame([99], array_map(static fn (Service $s) => $s->id, $workingParticipant->additionalServices)); } + public function testReconcileImmutableCategoriesLocksRentalsFromFourDaysBeforeTravelStart(): void + { + CarbonImmutable::setTestNow('2026-01-08 12:00:00'); + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2026-01-10 09:00:00'); + $travel->additionalServicesMutable = true; + $travel->transportationServicesMutable = true; + $travel->pickupsMutable = true; + + $workingParticipant = new ParticipantDto(); + $workingParticipant->index = 0; + $workingParticipant->rentals = [$this->createService(99)]; + + $workingDto = new BookingDto($travel, 1); + $workingDto->participants = [$workingParticipant]; + + $baselineParticipant = new ParticipantDto(); + $baselineParticipant->index = 0; + $baselineParticipant->rentals = [$this->createService(10)]; + + $baselineDto = new BookingDto($travel, 1); + $baselineDto->participants = [$baselineParticipant]; + + $processor = $this->createMock(BookingDataProcessor::class); + $processor->expects($this->once()) + ->method('createBookingDtoFromBooking') + ->willReturn($baselineDto); + + $service = new BookingEditSubmitGuardService($processor); + + $changed = $service->reconcileImmutableCategories($workingDto, new Booking()); + + $this->assertTrue($changed); + $this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals)); + } + private function createService(int $id): Service { $service = new Service();