fix: stop offering unbookable services in the booking edit flow
This commit is contained in:
@@ -143,6 +143,31 @@ class Booking
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a participant already holds a service in this booking.
|
||||||
|
*
|
||||||
|
* This is the BusPro-side baseline, not the working edit selection: it answers
|
||||||
|
* "did the participant have this service when the booking was last read back".
|
||||||
|
* Services already held may be kept regardless of their current status or
|
||||||
|
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
|
||||||
|
* Teilnehmerzuordnung überein" on the next update.
|
||||||
|
*
|
||||||
|
* @param int $participantIndex The participant index to search for
|
||||||
|
* @param int $serviceId The service ID to look for
|
||||||
|
*
|
||||||
|
* @return bool True if the participant already holds the service
|
||||||
|
*/
|
||||||
|
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
|
||||||
|
{
|
||||||
|
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
|
||||||
|
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves transportation service for a specific participant and direction.
|
* Retrieves transportation service for a specific participant and direction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -281,9 +281,9 @@ class TravelLoader extends AbstractLoader
|
|||||||
/**
|
/**
|
||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* Updates the remaining contingent and the live status of additional and
|
||||||
* services, and the allowed booking status, based on the provided
|
* transportation services, and the allowed booking status, based on the
|
||||||
* availability data.
|
* provided availability data.
|
||||||
*
|
*
|
||||||
* @param Travel $travel The travel object to update
|
* @param Travel $travel The travel object to update
|
||||||
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
||||||
@@ -293,8 +293,19 @@ class TravelLoader extends AbstractLoader
|
|||||||
$serviceAvailabilities = $availabilities->getServices();
|
$serviceAvailabilities = $availabilities->getServices();
|
||||||
|
|
||||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
if (false === array_key_exists($service->id, $serviceAvailabilities)) {
|
||||||
$service->available = $serviceAvailabilities[$service->id]->available;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$availability = $serviceAvailabilities[$service->id];
|
||||||
|
$service->available = $availability->available;
|
||||||
|
|
||||||
|
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||||
|
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||||
|
// response actually carries one, so a response that omits the attribute does not wipe
|
||||||
|
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||||
|
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$service->status = $availability->status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels();
|
||||||
|
if ([] !== $droppedDraftServiceLabels) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $droppedDraftServiceLabels)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch booking data for display (surcharges, canceled status, etc.)
|
// Fetch booking data for display (surcharges, canceled status, etc.)
|
||||||
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
||||||
if (null === $bookingData || $bookingData instanceof Notification) {
|
if (null === $bookingData || $bookingData instanceof Notification) {
|
||||||
@@ -185,6 +193,13 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $result->unbookableServicesReverted) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $result->unbookableServicesReverted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
switch ($result->status) {
|
switch ($result->status) {
|
||||||
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
||||||
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) {
|
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return $attributes;
|
return $attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) {
|
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
|
||||||
$fieldOptions['attr']['readonly'] = true;
|
$fieldOptions['attr']['readonly'] = true;
|
||||||
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht';
|
$fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fieldOptions;
|
return $fieldOptions;
|
||||||
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
*/
|
*/
|
||||||
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
|
||||||
// For non-create workflows, don't apply availability restrictions
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a service should be rendered as read-only.
|
* Determines if a service should be rendered as read-only.
|
||||||
*
|
*
|
||||||
* This method intelligently handles readonly state for services in both create and edit modes:
|
* Both modes share one rule set. The already-held carve-out inside
|
||||||
*
|
* ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
|
||||||
* - CREATE MODE: Uses existing availability calculator logic
|
* service that is now sold out or on request, so no mode-specific branch is needed here.
|
||||||
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
|
|
||||||
*
|
|
||||||
* This prevents fingerprint false positives in edit mode by allowing participants to keep
|
|
||||||
* services they already have, even if those services are now fully booked.
|
|
||||||
*
|
*
|
||||||
* @param Service $service The service to check
|
* @param Service $service The service to check
|
||||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||||
* @param int $participantIndex Index of the participant currently selecting services
|
* @param int $participantIndex Index of the participant currently selecting services
|
||||||
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
|
|
||||||
*
|
*
|
||||||
* @return bool True if the service should be read-only
|
* @return bool True if the service should be read-only
|
||||||
*/
|
*/
|
||||||
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
|
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
// In CREATE mode, use existing availability logic
|
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
||||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
|
||||||
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In EDIT mode, apply intelligent readonly logic
|
|
||||||
// If service is available (available > 0), it's never readonly
|
|
||||||
if (null !== $service->available && $service->available > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service is unavailable - check if participant already has it
|
|
||||||
$participant = $bookingDto->getParticipant($participantIndex);
|
|
||||||
if (null === $participant) {
|
|
||||||
return true; // Readonly if no participant data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if participant has this service based on field type
|
|
||||||
$participantHasService = match ($fieldName) {
|
|
||||||
'courses' => $this->hasServiceById($participant->courses, $service->id),
|
|
||||||
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
|
|
||||||
'board' => $this->hasServiceById($participant->board, $service->id),
|
|
||||||
'veg' => $participant->veg?->id === $service->id,
|
|
||||||
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
|
|
||||||
'skiPass' => $participant->skiPass?->id === $service->id,
|
|
||||||
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
|
|
||||||
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make readonly only if participant doesn't have it
|
|
||||||
return false === $participantHasService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tooltip explaining why a service is rendered read-only.
|
||||||
|
*
|
||||||
|
* Only called for a service that is already known to be blocked, so the status alone
|
||||||
|
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
|
||||||
|
* never fires there.
|
||||||
|
*/
|
||||||
|
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
|
||||||
|
{
|
||||||
|
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
|
||||||
|
&& Constants::STATUS_ON_REQUEST === $service->status) {
|
||||||
|
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ausgebucht';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters services based on participant's age constraints.
|
* Filters services based on participant's age constraints.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult
|
|||||||
public const string STATUS_TIMEOUT = 'timeout';
|
public const string STATUS_TIMEOUT = 'timeout';
|
||||||
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $unbookableServicesReverted Labels of services dropped because they are
|
||||||
|
* no longer bookable, reported separately from
|
||||||
|
* the mutability revert: the category is still
|
||||||
|
* mutable, the service is not bookable
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $status,
|
public string $status,
|
||||||
public ?string $message = null,
|
public ?string $message = null,
|
||||||
public bool $immutableChangesReverted = false,
|
public bool $immutableChangesReverted = false,
|
||||||
|
public array $unbookableServicesReverted = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class BookingEditDataLoader
|
|||||||
|
|
||||||
private bool $draftRestored = false;
|
private bool $draftRestored = false;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
@@ -53,6 +56,17 @@ class BookingEditDataLoader
|
|||||||
return $this->draftRestored;
|
return $this->draftRestored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the drafted services dropped during the last load because they are no longer
|
||||||
|
* bookable. Reset on each call to loadFormData(), like isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedDraftServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedDraftServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads booking data from session or initializes from API.
|
* Loads booking data from session or initializes from API.
|
||||||
*
|
*
|
||||||
@@ -72,6 +86,7 @@ class BookingEditDataLoader
|
|||||||
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
||||||
{
|
{
|
||||||
$this->draftRestored = false;
|
$this->draftRestored = false;
|
||||||
|
$this->droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||||
|
|
||||||
@@ -177,6 +192,10 @@ class BookingEditDataLoader
|
|||||||
// it would tell the user their draft was restored for edits they never made.
|
// it would tell the user their draft was restored for edits they never made.
|
||||||
$this->draftRestored = true === $applied
|
$this->draftRestored = true === $applied
|
||||||
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
||||||
|
|
||||||
|
if (true === $applied) {
|
||||||
|
$this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
*/
|
*/
|
||||||
class BookingEditDraftManager
|
class BookingEditDraftManager
|
||||||
{
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingEditDraftRepository $draftRepository,
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
@@ -32,6 +35,19 @@ class BookingEditDraftManager
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the services dropped during the last applyDraftToDto() call.
|
||||||
|
*
|
||||||
|
* Reset on each call, so the caller can report them to the user. Mirrors
|
||||||
|
* BookingEditDataLoader::isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an existing draft for a user and booking combination.
|
* Finds an existing draft for a user and booking combination.
|
||||||
*
|
*
|
||||||
@@ -129,6 +145,9 @@ class BookingEditDraftManager
|
|||||||
*/
|
*/
|
||||||
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
||||||
{
|
{
|
||||||
|
$this->droppedServiceLabels = [];
|
||||||
|
$droppedServiceLabels = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$formData = $draft->getFormData();
|
$formData = $draft->getFormData();
|
||||||
|
|
||||||
@@ -149,16 +168,25 @@ class BookingEditDraftManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->participantApplier->apply(
|
$droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply(
|
||||||
$dto,
|
$dto,
|
||||||
$index,
|
$index,
|
||||||
$dto->participants[$index],
|
$dto->participants[$index],
|
||||||
$participantData,
|
$participantData,
|
||||||
$travel,
|
$travel,
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels));
|
||||||
|
|
||||||
|
if ([] !== $this->droppedServiceLabels) {
|
||||||
|
$this->logger->info('Dropped drafted services that are no longer bookable', [
|
||||||
|
'booking_id' => $draft->getBookingId(),
|
||||||
|
'services' => $this->droppedServiceLabels,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->info('Applied draft to booking DTO', [
|
$this->logger->info('Applied draft to booking DTO', [
|
||||||
'booking_id' => $draft->getBookingId(),
|
'booking_id' => $draft->getBookingId(),
|
||||||
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
|||||||
@@ -24,14 +24,24 @@ use App\Form\Model\ParticipantDto;
|
|||||||
*/
|
*/
|
||||||
class BookingEditDraftMerger
|
class BookingEditDraftMerger
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $data */
|
public function __construct(
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of drafted services dropped because they are no longer bookable
|
||||||
|
*/
|
||||||
public function apply(
|
public function apply(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $participantIndex,
|
int $participantIndex,
|
||||||
ParticipantDto $participant,
|
ParticipantDto $participant,
|
||||||
array $data,
|
array $data,
|
||||||
Travel $travel,
|
Travel $travel,
|
||||||
): void {
|
): array {
|
||||||
|
$droppedServiceLabels = [];
|
||||||
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
||||||
|
|
||||||
// Personal data
|
// Personal data
|
||||||
@@ -68,10 +78,19 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Service selections (gated per-category by travel mutability flags)
|
// Service selections (gated per-category by travel mutability flags)
|
||||||
if (isset($data['services']) && true === is_array($data['services'])) {
|
if (isset($data['services']) && true === is_array($data['services'])) {
|
||||||
$this->applyServiceSelections($participant, $data['services'], $travel);
|
$this->applyServiceSelections(
|
||||||
|
$participant,
|
||||||
|
$data['services'],
|
||||||
|
$travel,
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex,
|
||||||
|
$droppedServiceLabels
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant->normalizeLoadedData();
|
$participant->normalizeLoadedData();
|
||||||
|
|
||||||
|
return array_values(array_unique($droppedServiceLabels));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
||||||
@@ -205,16 +224,25 @@ class BookingEditDraftMerger
|
|||||||
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
||||||
* always replace API data, since users can intentionally clear these selections.
|
* always replace API data, since users can intentionally clear these selections.
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $data */
|
/**
|
||||||
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void
|
* @param array<string, mixed> $data
|
||||||
{
|
* @param list<string> $droppedServiceLabels Collects what was dropped, by reference
|
||||||
|
*/
|
||||||
|
private function applyServiceSelections(
|
||||||
|
ParticipantDto $participant,
|
||||||
|
array $data,
|
||||||
|
Travel $travel,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): void {
|
||||||
// Additional services category — only apply draft data when services are mutable.
|
// Additional services category — only apply draft data when services are mutable.
|
||||||
// When immutable, the booking's current services must be preserved as-is to avoid
|
// When immutable, the booking's current services must be preserved as-is to avoid
|
||||||
// API rejection (stale draft data could differ from the locked booking state).
|
// API rejection (stale draft data could differ from the locked booking state).
|
||||||
if (true === $travel->additionalServicesMutable) {
|
if (true === $travel->additionalServicesMutable) {
|
||||||
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
||||||
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->skiPass = $resolved;
|
$participant->skiPass = $resolved;
|
||||||
}
|
}
|
||||||
@@ -222,17 +250,17 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Courses (array) - overwrite strategy: user can deselect all
|
// Courses (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
||||||
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices);
|
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board (array) - overwrite strategy: user can deselect all
|
// Board (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
||||||
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices);
|
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
||||||
$resolved = $this->resolveService($data['veg'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->veg = $resolved;
|
$participant->veg = $resolved;
|
||||||
}
|
}
|
||||||
@@ -240,12 +268,12 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Rentals (array) - overwrite strategy: user can deselect all
|
// Rentals (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
||||||
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices);
|
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
||||||
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->rentalInsurance = $resolved;
|
$participant->rentalInsurance = $resolved;
|
||||||
$participant->rentalInsuranceSelected = true;
|
$participant->rentalInsuranceSelected = true;
|
||||||
@@ -255,7 +283,7 @@ class BookingEditDraftMerger
|
|||||||
// Additional services (array) - overwrite strategy with mandatory service preservation
|
// Additional services (array) - overwrite strategy with mandatory service preservation
|
||||||
// User can deselect optional services, but mandatory services from API must be preserved
|
// User can deselect optional services, but mandatory services from API must be preserved
|
||||||
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
||||||
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices);
|
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
$participant->additionalServices = $this->preserveMandatoryServices(
|
$participant->additionalServices = $this->preserveMandatoryServices(
|
||||||
$resolvedFromDraft,
|
$resolvedFromDraft,
|
||||||
$participant->additionalServices,
|
$participant->additionalServices,
|
||||||
@@ -268,7 +296,7 @@ class BookingEditDraftMerger
|
|||||||
if (true === $travel->transportationServicesMutable) {
|
if (true === $travel->transportationServicesMutable) {
|
||||||
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationOutbound = $resolved;
|
$participant->transportationOutbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -276,7 +304,7 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationInbound = $resolved;
|
$participant->transportationInbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -368,37 +396,84 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*/
|
*/
|
||||||
private function resolveService(?int $serviceId, array $services): ?object
|
private function resolveService(
|
||||||
{
|
?int $serviceId,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): ?object {
|
||||||
if (null === $serviceId) {
|
if (null === $serviceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $services[$serviceId] ?? null;
|
$service = $services[$serviceId] ?? null;
|
||||||
|
|
||||||
|
if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, int> $serviceIds
|
* @param array<int, int> $serviceIds
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*
|
*
|
||||||
* @return array<int, object>
|
* @return array<int, object>
|
||||||
*/
|
*/
|
||||||
private function resolveServiceArray(array $serviceIds, array $services): array
|
private function resolveServiceArray(
|
||||||
{
|
array $serviceIds,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): array {
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
$addedIds = [];
|
$addedIds = [];
|
||||||
|
|
||||||
foreach ($serviceIds as $serviceId) {
|
foreach ($serviceIds as $serviceId) {
|
||||||
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) {
|
if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) {
|
||||||
$resolved[] = $services[$serviceId];
|
continue;
|
||||||
$addedIds[$serviceId] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$service = $services[$serviceId];
|
||||||
|
|
||||||
|
if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = $service;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $resolved;
|
return $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a drafted selection must not be restored.
|
||||||
|
*
|
||||||
|
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
|
||||||
|
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on
|
||||||
|
* every re-entry and BusPro rejects the whole update again. Services the participant already
|
||||||
|
* holds are carved out by the availability rule itself, so they still restore.
|
||||||
|
*/
|
||||||
|
private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
if (false === $service instanceof Service || null === $service->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
||||||
{
|
{
|
||||||
if (null === $pickupId) {
|
if (null === $pickupId) {
|
||||||
|
|||||||
@@ -24,9 +24,72 @@ class BookingEditSubmitGuard
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts selections of services that are no longer bookable.
|
||||||
|
*
|
||||||
|
* The read-only state in the participant form is presentation only, so a stale session or
|
||||||
|
* a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'.
|
||||||
|
* Sending one makes BusPro reject the entire update - every participant's changes with it -
|
||||||
|
* so the selection is reverted to what the live booking already holds.
|
||||||
|
*
|
||||||
|
* Services the participant already holds are never touched: the availability rule carves
|
||||||
|
* them out, and withdrawing one would break the Leistung/Teilnehmer counts.
|
||||||
|
*
|
||||||
|
* Expects $workingDto->booking to be the fresh booking, as the availability rule reads its
|
||||||
|
* already-held carve-out from there.
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of the services that were reverted, for user feedback
|
||||||
|
*/
|
||||||
|
public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array
|
||||||
|
{
|
||||||
|
$baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking());
|
||||||
|
$revertedLabels = [];
|
||||||
|
|
||||||
|
foreach ($workingDto->participants as $index => $participant) {
|
||||||
|
$baseline = $baselineDto->participants[$index] ?? null;
|
||||||
|
if (null === $baseline) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isBlocked = fn (?Service $service): bool => null !== $service
|
||||||
|
&& null !== $service->id
|
||||||
|
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index);
|
||||||
|
|
||||||
|
// Multi-selection fields: drop the blocked additions, keep everything else
|
||||||
|
foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) {
|
||||||
|
$kept = [];
|
||||||
|
foreach ($participant->{$field} as $service) {
|
||||||
|
if (true === $isBlocked($service)) {
|
||||||
|
$revertedLabels[] = (string) $service->label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($kept) !== count($participant->{$field})) {
|
||||||
|
$participant->{$field} = $kept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-selection fields: fall back to what the booking already holds rather than
|
||||||
|
// clearing, so a required field does not end up empty
|
||||||
|
foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) {
|
||||||
|
if (false === $isBlocked($participant->{$field})) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$revertedLabels[] = (string) $participant->{$field}->label;
|
||||||
|
$participant->{$field} = $baseline->{$field};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($revertedLabels));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverts immutable category changes to fresh booking values.
|
* Reverts immutable category changes to fresh booking values.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -63,11 +63,32 @@ class BookingEditSubmitter
|
|||||||
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The guard checks service status and contingent, so it needs the live figures rather
|
||||||
|
// than whatever was cached when the edit session started.
|
||||||
|
$availabilities = $this->travelDataService->getAvailabilityData(
|
||||||
|
$freshBookingData->dateId,
|
||||||
|
cached: true,
|
||||||
|
forceRefresh: true
|
||||||
|
);
|
||||||
|
if (null !== $availabilities) {
|
||||||
|
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
|
||||||
|
}
|
||||||
|
|
||||||
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
||||||
if (true === $immutableChangesReverted) {
|
$unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData);
|
||||||
|
|
||||||
|
if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) {
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $unbookableServicesReverted) {
|
||||||
|
$this->logger->warning('Reverted selections of services that are no longer bookable', [
|
||||||
|
'email' => $email,
|
||||||
|
'booking_id' => $bookingId,
|
||||||
|
'services' => $unbookableServicesReverted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->apiClient->updateBooking($bookingDto, true);
|
$response = $this->apiClient->updateBooking($bookingDto, true);
|
||||||
if ($response instanceof Notification) {
|
if ($response instanceof Notification) {
|
||||||
@@ -83,6 +104,7 @@ class BookingEditSubmitter
|
|||||||
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
||||||
$response->message,
|
$response->message,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +121,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_SUCCESS,
|
BookingEditSubmissionResult::STATUS_SUCCESS,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +136,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
||||||
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (TimeoutException) {
|
} catch (TimeoutException) {
|
||||||
$this->logger->error('Booking update timeout', [
|
$this->logger->error('Booking update timeout', [
|
||||||
@@ -124,12 +148,14 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (ApiClientException) {
|
} catch (ApiClientException) {
|
||||||
return new BookingEditSubmissionResult(
|
return new BookingEditSubmissionResult(
|
||||||
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
|
|||||||
*
|
*
|
||||||
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
* 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 array<int, Service> $services Array of Service objects to filter
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @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
|
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
|
// Services without an ID cannot be resolved against travel data - treat as available
|
||||||
if (null === $service->id) {
|
if (null === $service->id) {
|
||||||
return true;
|
return true;
|
||||||
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
|
|||||||
|
|
||||||
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
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
|
* contingent is exhausted, either at the API level or through selections made by the
|
||||||
* other participants of the current booking.
|
* 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 int $serviceId The ID of the service to check
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
|
|||||||
return false;
|
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
|
// A booking stop blocks the service regardless of its contingent
|
||||||
if (Constants::STATUS_BLOCKED === $service->status) {
|
if (Constants::STATUS_BLOCKED === $service->status) {
|
||||||
return true;
|
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 no availability tracking (null), service is unlimited and available
|
||||||
if (null === $service->available) {
|
if (null === $service->available) {
|
||||||
return false;
|
return false;
|
||||||
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
|
|||||||
// For services with positive availability, calculate remaining based on booking selections
|
// For services with positive availability, calculate remaining based on booking selections
|
||||||
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Availability;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
|
||||||
|
*
|
||||||
|
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
|
||||||
|
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
|
||||||
|
* availability rule read the status from the nightly XML export instead.
|
||||||
|
*/
|
||||||
|
class TravelLoaderAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private TravelLoader $loader;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->loader = new TravelLoader(
|
||||||
|
$this->createStub(HotelLoader::class),
|
||||||
|
$this->createStub(TravelParser::class),
|
||||||
|
'https://example.test',
|
||||||
|
$this->createStub(CacheInterface::class),
|
||||||
|
$this->createStub(FilesystemOperator::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesStatusAlongsideAvailability(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, null, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, ' ', 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesTransportationServicesToo(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->transportationServices = [80 => $service];
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
|
||||||
|
$this->assertSame(0, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Service> $services */
|
||||||
|
private function createTravel(array $services): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$indexed[$service->id] = $service;
|
||||||
|
}
|
||||||
|
$travel->additionalServices = $indexed;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(int $id, string $status, ?int $available): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->status = $status;
|
||||||
|
$service->available = $available;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
|
||||||
|
{
|
||||||
|
$availability = new Availability();
|
||||||
|
$availability->serviceId = $serviceId;
|
||||||
|
$availability->status = $status;
|
||||||
|
$availability->available = $available;
|
||||||
|
|
||||||
|
return $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Availability> $availabilities */
|
||||||
|
private function createResponse(array $availabilities): ServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($availabilities as $availability) {
|
||||||
|
$indexed[$availability->serviceId] = $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceAvailabilityResponse($indexed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\InsuranceManager;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use App\Service\ServiceLabelFormatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit mode applies the availability rules instead of bypassing them.
|
||||||
|
*
|
||||||
|
* Edit used to skip availability entirely for every category except ski passes, so a course
|
||||||
|
* that had sold out or moved to 'Anfrage' was still offered. Selecting one made BusPro refuse
|
||||||
|
* the whole update ("Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers
|
||||||
|
* (F)"), because the participants of an existing booking are fixed at status 'F'.
|
||||||
|
*
|
||||||
|
* Courses stand in for the categories that render read-only rather than dropping the option.
|
||||||
|
*/
|
||||||
|
class ParticipantFieldOptionsProviderEditAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private ParticipantFieldOptionsProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$translator = $this->createStub(TranslatorInterface::class);
|
||||||
|
$translator->method('trans')->willReturnArgument(0);
|
||||||
|
|
||||||
|
$this->provider = new ParticipantFieldOptionsProvider(
|
||||||
|
new ServiceAvailabilityCalculator(),
|
||||||
|
$this->createStub(InsuranceManager::class),
|
||||||
|
$this->createStub(BookingPriceCalculator::class),
|
||||||
|
new ServiceLabelFormatter(),
|
||||||
|
$translator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseIsReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('readonly', $attributes);
|
||||||
|
$this->assertSame(
|
||||||
|
'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.',
|
||||||
|
$attributes['data-tooltip']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSoldOutCourseIsReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, available: 0);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('readonly', $attributes);
|
||||||
|
$this->assertSame('ausgebucht', $attributes['data-tooltip']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseStaysSelectableWhenAlreadyBooked(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course], heldServiceIds: [1]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
$this->assertArrayNotHasKey('data-tooltip', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnlimitedCourseIsNotReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
// A null contingent means unlimited. The previous edit branch inverted this and made
|
||||||
|
// such a service read-only for anyone who did not already hold it.
|
||||||
|
$course = $this->createCourse(id: 1, available: null);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseStaysSelectableInCreateMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createBookingDto([$course]);
|
||||||
|
|
||||||
|
$this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode());
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function getChoiceAttributes(BookingDto $bookingDto, Service $service): array
|
||||||
|
{
|
||||||
|
$options = $this->provider->getFieldOptions('courses', $bookingDto, 0);
|
||||||
|
|
||||||
|
return $options['choice_attr']($service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Service[] $courses */
|
||||||
|
private function createBookingDto(array $courses): BookingDto
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2027-01-09');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2027-01-16');
|
||||||
|
|
||||||
|
foreach ($courses as $course) {
|
||||||
|
$travel->additionalServices[$course->id] = $course;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Service[] $courses
|
||||||
|
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
|
||||||
|
*/
|
||||||
|
private function createEditBookingDto(array $courses, array $heldServiceIds = []): BookingDto
|
||||||
|
{
|
||||||
|
$bookingDto = $this->createBookingDto($courses);
|
||||||
|
|
||||||
|
$heldServices = [];
|
||||||
|
foreach ($heldServiceIds as $serviceId) {
|
||||||
|
$held = clone $bookingDto->travel->additionalServices[$serviceId];
|
||||||
|
$held->mapping = [0];
|
||||||
|
$heldServices[$serviceId] = $held;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = $heldServices;
|
||||||
|
$bookingDto->booking = $booking;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCourse(int $id, ?int $available = 10, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = sprintf('Kurs %d', $id);
|
||||||
|
$service->subType = Constants::TOKEN_COURSES;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
$service->price = (float) $id;
|
||||||
|
$service->available = $available;
|
||||||
|
$service->status = $status;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use App\Repository\BookingEditDraftRepository;
|
|||||||
use App\Service\BookingChangeTracker;
|
use App\Service\BookingChangeTracker;
|
||||||
use App\Service\BookingEditDraftManager;
|
use App\Service\BookingEditDraftManager;
|
||||||
use App\Service\BookingEditDraftMerger;
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
@@ -37,7 +38,7 @@ class BookingEditDraftManagerMutabilityTest extends TestCase
|
|||||||
$this->createStub(BookingEditDraftRepository::class),
|
$this->createStub(BookingEditDraftRepository::class),
|
||||||
$this->createStub(EntityManagerInterface::class),
|
$this->createStub(EntityManagerInterface::class),
|
||||||
$this->createStub(BookingChangeTracker::class),
|
$this->createStub(BookingChangeTracker::class),
|
||||||
new BookingEditDraftMerger(),
|
new BookingEditDraftMerger(new ServiceAvailabilityCalculator()),
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Travel;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Service\BookingEditDraftMerger;
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +27,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->merger = new BookingEditDraftMerger();
|
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that draft restoration does not re-arm a selection BusPro would reject.
|
||||||
|
*
|
||||||
|
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
|
||||||
|
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice comes back on every
|
||||||
|
* re-entry and BusPro refuses the whole update again. Services the participant already holds
|
||||||
|
* still restore, because withdrawing one breaks the Leistung/Teilnehmer counts.
|
||||||
|
*/
|
||||||
|
class BookingEditDraftMergerServiceAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private BookingEditDraftMerger $merger;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftDropsSkiPassThatIsNowOnRequest(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
|
||||||
|
|
||||||
|
$this->assertNull($participant->skiPass);
|
||||||
|
$this->assertSame(['Skipass 6 Tage'], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftRestoresSkiPassThatIsStillFree(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage');
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
|
||||||
|
|
||||||
|
$this->assertSame(60, $participant->skiPass?->id);
|
||||||
|
$this->assertSame([], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftRestoresOnRequestSkiPassTheParticipantAlreadyHolds(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$held = clone $skiPass;
|
||||||
|
$held->mapping = [1];
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = [60 => $held];
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], $booking);
|
||||||
|
|
||||||
|
$this->assertSame(60, $participant->skiPass?->id);
|
||||||
|
$this->assertSame([], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftDropsOnlyTheUnbookableEntryOfAMultiSelectField(): void
|
||||||
|
{
|
||||||
|
$bookable = $this->createCourse(70, 'Snowboardkurs');
|
||||||
|
$onRequest = $this->createCourse(71, 'Skikurs', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([70 => $bookable, 71 => $onRequest]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['courses' => [70, 71]], new Booking());
|
||||||
|
|
||||||
|
$this->assertSame([70], array_map(static fn (Service $s) => $s->id, $participant->courses));
|
||||||
|
$this->assertSame(['Skikurs'], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $services
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function apply(Travel $travel, ParticipantDto $participant, array $services, Booking $booking): array
|
||||||
|
{
|
||||||
|
$dto = new BookingDto($travel, 1);
|
||||||
|
$dto->participants = [1 => $participant];
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$dto->booking = $booking;
|
||||||
|
|
||||||
|
return $this->merger->apply($dto, 1, $participant, ['services' => $services], $travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Service> $additionalServices */
|
||||||
|
private function createTravel(array $additionalServices): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = $additionalServices;
|
||||||
|
$travel->additionalServicesMutable = true;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSkiPass(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
return $this->createService($id, $label, Constants::TOKEN_SKI_PASS, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCourse(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
return $this->createService($id, $label, Constants::TOKEN_COURSES, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(int $id, string $label, string $subType, string $status): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = $label;
|
||||||
|
$service->subType = $subType;
|
||||||
|
$service->status = $status;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Pickup;
|
use App\BusProNet\Model\Pickup;
|
||||||
@@ -12,6 +13,7 @@ use App\BusProNet\Model\Travel;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Service\BookingEditSubmitGuard;
|
use App\Service\BookingEditSubmitGuard;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -136,6 +138,85 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
|
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRevertUnbookableServiceAdditionsDropsOnRequestAddition(): void
|
||||||
|
{
|
||||||
|
$onRequest = $this->createService(99);
|
||||||
|
$onRequest->label = 'Bus-Hinfahrt';
|
||||||
|
$onRequest->subType = Constants::TOKEN_SKI_PASS;
|
||||||
|
$onRequest->status = Constants::STATUS_ON_REQUEST;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [99 => $onRequest];
|
||||||
|
|
||||||
|
$workingParticipant = new ParticipantDto();
|
||||||
|
$workingParticipant->index = 0;
|
||||||
|
$workingParticipant->skiPass = $onRequest;
|
||||||
|
|
||||||
|
$freshBooking = new Booking();
|
||||||
|
|
||||||
|
$workingDto = new BookingDto($travel, 1);
|
||||||
|
$workingDto->participants = [$workingParticipant];
|
||||||
|
$workingDto->booking = $freshBooking;
|
||||||
|
|
||||||
|
$baselineParticipant = new ParticipantDto();
|
||||||
|
$baselineParticipant->index = 0;
|
||||||
|
|
||||||
|
$baselineDto = new BookingDto($travel, 1);
|
||||||
|
$baselineDto->participants = [$baselineParticipant];
|
||||||
|
|
||||||
|
$processor = $this->createStub(BookingDataProcessor::class);
|
||||||
|
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
|
||||||
|
|
||||||
|
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
|
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
|
||||||
|
|
||||||
|
$this->assertSame(['Bus-Hinfahrt'], $reverted);
|
||||||
|
$this->assertNull($workingParticipant->skiPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevertUnbookableServiceAdditionsKeepsAServiceTheParticipantAlreadyHolds(): void
|
||||||
|
{
|
||||||
|
$onRequest = $this->createService(99);
|
||||||
|
$onRequest->label = 'Bus-Hinfahrt';
|
||||||
|
$onRequest->subType = Constants::TOKEN_SKI_PASS;
|
||||||
|
$onRequest->status = Constants::STATUS_ON_REQUEST;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [99 => $onRequest];
|
||||||
|
|
||||||
|
$held = clone $onRequest;
|
||||||
|
$held->mapping = [0];
|
||||||
|
|
||||||
|
$freshBooking = new Booking();
|
||||||
|
$freshBooking->additionalServices = [99 => $held];
|
||||||
|
|
||||||
|
$workingParticipant = new ParticipantDto();
|
||||||
|
$workingParticipant->index = 0;
|
||||||
|
$workingParticipant->skiPass = $onRequest;
|
||||||
|
|
||||||
|
$workingDto = new BookingDto($travel, 1);
|
||||||
|
$workingDto->participants = [$workingParticipant];
|
||||||
|
$workingDto->booking = $freshBooking;
|
||||||
|
|
||||||
|
$baselineParticipant = new ParticipantDto();
|
||||||
|
$baselineParticipant->index = 0;
|
||||||
|
$baselineParticipant->skiPass = $held;
|
||||||
|
|
||||||
|
$baselineDto = new BookingDto($travel, 1);
|
||||||
|
$baselineDto->participants = [$baselineParticipant];
|
||||||
|
|
||||||
|
$processor = $this->createStub(BookingDataProcessor::class);
|
||||||
|
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
|
||||||
|
|
||||||
|
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
|
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
|
||||||
|
|
||||||
|
$this->assertSame([], $reverted);
|
||||||
|
$this->assertSame($onRequest, $workingParticipant->skiPass);
|
||||||
|
}
|
||||||
|
|
||||||
private function createService(int $id): Service
|
private function createService(int $id): Service
|
||||||
{
|
{
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Service;
|
use App\BusProNet\Model\Service;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
@@ -85,6 +86,95 @@ class ServiceAvailabilityCalculatorTest extends TestCase
|
|||||||
$this->assertSame([1, 4], array_keys($filtered));
|
$this->assertSame([1, 4], array_keys($filtered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceStaysAvailableInCreateMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices([$skiPass]);
|
||||||
|
|
||||||
|
// Create may still move the whole booking to status 'A', so on request is bookable there
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceIsUnavailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]);
|
||||||
|
|
||||||
|
// Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->autoBook = true;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->mandatory = true;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
// Blocking a service the mandatory-services validator requires makes the form unsatisfiable
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->mandatory = true;
|
||||||
|
$service->category = Constants::CATEGORY_TRANSPORTATION;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
// pflicht on a transport leg means "pick one of this group", not "compulsory"
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void
|
||||||
|
{
|
||||||
|
$first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$first, $second]);
|
||||||
|
|
||||||
|
$filtered = $this->calculator->filterAvailableServices(
|
||||||
|
$bookingDto->travel->additionalServices,
|
||||||
|
$bookingDto,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame([1, 2], array_keys($filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void
|
||||||
|
{
|
||||||
|
$soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED);
|
||||||
|
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]);
|
||||||
|
|
||||||
|
$filtered = $this->calculator->filterAvailableServices(
|
||||||
|
$bookingDto->travel->additionalServices,
|
||||||
|
$bookingDto,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame([], array_keys($filtered));
|
||||||
|
}
|
||||||
|
|
||||||
private function createParkingService(int $id, int $available): Service
|
private function createParkingService(int $id, int $available): Service
|
||||||
{
|
{
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
@@ -123,4 +213,27 @@ class ServiceAvailabilityCalculatorTest extends TestCase
|
|||||||
|
|
||||||
return $bookingDto;
|
return $bookingDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Service> $services
|
||||||
|
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
|
||||||
|
*/
|
||||||
|
private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto
|
||||||
|
{
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices($services);
|
||||||
|
|
||||||
|
$heldServices = [];
|
||||||
|
foreach ($heldServiceIds as $serviceId) {
|
||||||
|
$held = clone $bookingDto->travel->additionalServices[$serviceId];
|
||||||
|
$held->mapping = [0];
|
||||||
|
$heldServices[$serviceId] = $held;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = $heldServices;
|
||||||
|
$bookingDto->booking = $booking;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user