345 lines
14 KiB
PHP
345 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Constants;
|
|
use App\BusProNet\Model\Service;
|
|
use App\BusProNet\Utility\DirectionMapper;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\ParticipantDto;
|
|
|
|
/**
|
|
* Calculates dynamic service availability based on current booking selections.
|
|
*
|
|
* This service tracks how many participants have selected each service within
|
|
* the current booking session and calculates remaining availability for
|
|
* display to other participants. This prevents overbooking within a single
|
|
* booking workflow.
|
|
*/
|
|
class ServiceAvailabilityCalculator
|
|
{
|
|
/**
|
|
* Calculate remaining availability for all services based on current participant selections.
|
|
*
|
|
* @param BookingDto $bookingDto The booking data with participant selections
|
|
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
|
*
|
|
* @return array<int, int> Array mapping service IDs to remaining availability counts
|
|
*/
|
|
public function calculateRemainingAvailability(BookingDto $bookingDto, int $currentParticipantIndex): array
|
|
{
|
|
$serviceUsage = $this->calculateServiceUsage($bookingDto, $currentParticipantIndex);
|
|
$remainingAvailability = [];
|
|
|
|
// Get all services from the travel data
|
|
$allServices = $this->getAllServicesFromTravel($bookingDto);
|
|
|
|
foreach ($allServices as $service) {
|
|
$originalAvailability = $service->available ?? null;
|
|
$usedCount = $serviceUsage[$service->id] ?? 0;
|
|
|
|
// Only track services that have availability limits set
|
|
if (null !== $originalAvailability && $originalAvailability > 0) {
|
|
$remaining = max(0, $originalAvailability - $usedCount);
|
|
$remainingAvailability[$service->id] = $remaining;
|
|
}
|
|
}
|
|
|
|
return $remainingAvailability;
|
|
}
|
|
|
|
/**
|
|
* Filter services array to only include those that are still bookable.
|
|
*
|
|
* 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
|
|
*
|
|
* @return array<int, Service> Filtered array containing only available services
|
|
*/
|
|
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
|
{
|
|
$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;
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a specific service is unavailable for the current participant.
|
|
*
|
|
* A service is unavailable when it carries a booking stop (Buchungsstop) or when its
|
|
* 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
|
|
*
|
|
* @return bool True if the service is unavailable
|
|
*/
|
|
public function isServiceUnavailable(int $serviceId, BookingDto $bookingDto, int $participantIndex): bool
|
|
{
|
|
$allServices = $this->getAllServicesFromTravel($bookingDto);
|
|
$service = null;
|
|
|
|
foreach ($allServices as $serviceObj) {
|
|
if ($serviceObj->id === $serviceId) {
|
|
$service = $serviceObj;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If service not found, treat as available (not unavailable)
|
|
if (null === $service) {
|
|
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;
|
|
}
|
|
|
|
// If availability is 0, service is sold out at the API level
|
|
if (0 === $service->available) {
|
|
return true;
|
|
}
|
|
|
|
// For services with positive availability, calculate remaining based on booking selections
|
|
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Calculate how many times each service has been selected by other participants.
|
|
*
|
|
* @param BookingDto $bookingDto The booking data with participant selections
|
|
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
|
*
|
|
* @return array<int, int> Array mapping service IDs to usage counts
|
|
*/
|
|
private function calculateServiceUsage(BookingDto $bookingDto, int $currentParticipantIndex): array
|
|
{
|
|
$serviceUsage = [];
|
|
|
|
foreach ($bookingDto->participants as $index => $participant) {
|
|
// Skip the current participant to avoid counting their potential selections
|
|
if ($index === $currentParticipantIndex) {
|
|
continue;
|
|
}
|
|
|
|
// Count service selections for this participant
|
|
$participantUsage = $this->countParticipantServiceUsage($participant);
|
|
|
|
foreach ($participantUsage as $serviceId => $count) {
|
|
$serviceUsage[$serviceId] = ($serviceUsage[$serviceId] ?? 0) + $count;
|
|
}
|
|
}
|
|
|
|
return $serviceUsage;
|
|
}
|
|
|
|
/**
|
|
* Count service usage for a single participant and add to the usage array.
|
|
*
|
|
* @param ParticipantDto $participant The participant DTO object
|
|
*
|
|
* @return array<int, int> Usage counts keyed by service ID
|
|
*/
|
|
private function countParticipantServiceUsage(ParticipantDto $participant): array
|
|
{
|
|
$serviceUsage = [];
|
|
|
|
// Board service (single selection)
|
|
foreach ($participant->board as $service) {
|
|
if (null !== $service->id) {
|
|
$serviceUsage[$service->id] = ($serviceUsage[$service->id] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Ski pass service (single selection)
|
|
if (null !== $participant->skiPass && null !== $participant->skiPass->id) {
|
|
$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;
|
|
}
|
|
|
|
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->id) {
|
|
$serviceUsage[$participant->transportationInbound->id] = ($serviceUsage[$participant->transportationInbound->id] ?? 0) + 1;
|
|
}
|
|
|
|
// Courses (multiple selection)
|
|
foreach ($participant->courses as $course) {
|
|
if (null !== $course->id) {
|
|
$serviceUsage[$course->id] = ($serviceUsage[$course->id] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Additional services (multiple selection)
|
|
foreach ($participant->additionalServices as $additionalService) {
|
|
if (null !== $additionalService->id) {
|
|
$serviceUsage[$additionalService->id] = ($serviceUsage[$additionalService->id] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Rentals (multiple selection)
|
|
foreach ($participant->rentals as $rental) {
|
|
if (null !== $rental->id) {
|
|
$serviceUsage[$rental->id] = ($serviceUsage[$rental->id] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return $serviceUsage;
|
|
}
|
|
|
|
/**
|
|
* Get all services from the travel data for availability calculation.
|
|
*
|
|
* @param BookingDto $bookingDto The booking data containing travel information
|
|
*
|
|
* @return array<Service> Array of all available services
|
|
*/
|
|
private function getAllServicesFromTravel(BookingDto $bookingDto): array
|
|
{
|
|
$allServices = [];
|
|
|
|
// Get all transportation services
|
|
$transportationServices = array_merge(
|
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL)
|
|
);
|
|
$allServices = array_merge($allServices, $transportationServices);
|
|
|
|
// Get additional services by category using proper constants
|
|
$additionalServiceTokens = [
|
|
Constants::TOKEN_COURSES,
|
|
Constants::TOKEN_ADDITIONAL,
|
|
Constants::TOKEN_RENTALS,
|
|
Constants::TOKEN_SKI_PASS,
|
|
Constants::TOKEN_BOARD,
|
|
Constants::TOKEN_PARKING,
|
|
];
|
|
|
|
foreach ($additionalServiceTokens as $token) {
|
|
$categoryServices = $bookingDto->travel->getAdditionalServicesBySubTypes($token);
|
|
$allServices = array_merge($allServices, $categoryServices);
|
|
}
|
|
|
|
return $allServices;
|
|
}
|
|
}
|