fix: stop offering unbookable services in the booking edit flow
This commit is contained in:
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
|
||||
*
|
||||
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
||||
*
|
||||
* Never empties a non-empty choice group on the on-request rule alone: on some termine
|
||||
* every outbound transport option or every ski pass is 'Anfrage', and an empty required
|
||||
* group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but
|
||||
* none selectable" as the participant being unable to travel. Sold-out and Buchungsstop
|
||||
* still empty a group, preserving the pre-existing semantics. Inert in create mode, where
|
||||
* the on-request rule cannot fire.
|
||||
*
|
||||
* @param array<int, Service> $services Array of Service objects to filter
|
||||
* @param BookingDto $bookingDto The booking data with participant selections
|
||||
* @param int $participantIndex The index of the participant currently filling the form
|
||||
@@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator
|
||||
*/
|
||||
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
||||
{
|
||||
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
||||
$filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
||||
// Services without an ID cannot be resolved against travel data - treat as available
|
||||
if (null === $service->id) {
|
||||
return true;
|
||||
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
|
||||
|
||||
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||
});
|
||||
|
||||
if ([] !== $filtered || [] === $services) {
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$services,
|
||||
fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto)
|
||||
&& Constants::STATUS_BLOCKED !== $service->status
|
||||
&& false === $this->isContingentExhausted($service, $bookingDto, $participantIndex)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator
|
||||
* contingent is exhausted, either at the API level or through selections made by the
|
||||
* other participants of the current booking.
|
||||
*
|
||||
* In edit mode one further rule applies: a service that is only available on request
|
||||
* (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at
|
||||
* status 'F', and BusPro derives a Leistung's status from the travel data and refuses to
|
||||
* attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status
|
||||
* des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem,
|
||||
* because the booking status can still move to 'A' there - hence the mode precondition.
|
||||
*
|
||||
* The rule is asymmetric: a service the participant already holds in the live booking
|
||||
* stays available, so it can be kept and re-sent.
|
||||
*
|
||||
* @param int $serviceId The ID of the service to check
|
||||
* @param BookingDto $bookingDto The booking data with participant selections
|
||||
* @param int $participantIndex The index of the participant currently filling the form
|
||||
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
|
||||
return false;
|
||||
}
|
||||
|
||||
// A service the participant already holds is always keepable, whatever its state now
|
||||
if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A booking stop blocks the service regardless of its contingent
|
||||
if (Constants::STATUS_BLOCKED === $service->status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// On request cannot be added to a participant whose status is already fixed
|
||||
if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isContingentExhausted($service, $bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a service has no contingent left.
|
||||
*
|
||||
* Covers both the API-level figure and the seats taken by the other participants of the
|
||||
* current booking.
|
||||
*/
|
||||
private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||
{
|
||||
// If no availability tracking (null), service is unlimited and available
|
||||
if (null === $service->available) {
|
||||
return false;
|
||||
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
|
||||
// For services with positive availability, calculate remaining based on booking selections
|
||||
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||
|
||||
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0;
|
||||
return ($remainingAvailability[$service->id] ?? $service->available) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the on-request rule blocks a service.
|
||||
*
|
||||
* Only applies in edit mode, and never to services the form would then be unable to
|
||||
* satisfy: auto-booked services and mandatory services outside the transport categories
|
||||
* are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the
|
||||
* form unsatisfiable. Mandatory transport legs are deliberately not exempt - there
|
||||
* pflicht="True" means "pick one of this group" rather than "compulsory", and exempting
|
||||
* them would exempt the bus legs this rule exists for.
|
||||
*/
|
||||
private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool
|
||||
{
|
||||
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Constants::STATUS_ON_REQUEST !== $service->status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $service->autoBook) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the participant already holds the service in the live booking.
|
||||
*
|
||||
* Reads the BusPro-side baseline (BookingDto::$booking), never the working selection,
|
||||
* which already carries whatever the customer just picked. Always false in create mode,
|
||||
* where there is no booking yet.
|
||||
*/
|
||||
private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||
{
|
||||
if (null === $bookingDto->booking || null === $service->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user