feat: implement mutability cutoff for rentals

addresses #869cca42x
This commit is contained in:
Björn Fromme
2026-03-17 10:29:43 +01:00
parent 752e6752b4
commit cb56a4fe46
9 changed files with 253 additions and 8 deletions
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
use Carbon\CarbonImmutable;
/**
* Condition that becomes true once a configurable day-based cutoff is reached.
*
* Cutoff semantics are inclusive and day-based:
* now(startOfDay) >= 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
);
}
}
@@ -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'] = [
+11 -2
View File
@@ -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
+26 -5
View File
@@ -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;
+1
View File
@@ -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']),
+18
View File
@@ -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.
*