From 647fa8e21274f8cf0ba5d87486869385faa21e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 21 Oct 2025 16:56:58 +0200 Subject: [PATCH] feat: unassign rooms from other participants when unavailable --- assets/controllers/toast_controller.js | 14 +- docs/PROJECT_OVERVIEW.md | 28 +- .../Traits/ParticipantCardFlowTrait.php | 25 +- .../ParticipantRoomChoiceLoader.php | 55 +-- .../ParticipantRoomChoiceLoaderFactory.php | 31 +- .../ParticipantAssignedRoomFieldHandler.php | 171 ++++++++- .../ParticipantFieldOptionsProvider.php | 8 +- ...articipantAssignedRoomFieldHandlerTest.php | 339 ++++++++++++++++++ 8 files changed, 576 insertions(+), 95 deletions(-) create mode 100644 tests/Form/Service/ParticipantAssignedRoomFieldHandlerTest.php diff --git a/assets/controllers/toast_controller.js b/assets/controllers/toast_controller.js index 969e96d..0c81e5f 100644 --- a/assets/controllers/toast_controller.js +++ b/assets/controllers/toast_controller.js @@ -11,16 +11,22 @@ export default class extends Controller { this.showToast(this.textValue, this.classValue) } - // Listen for HTMX notification events - document.addEventListener('showNotifications', this.handleNotifications.bind(this)) + // Bind handler for proper cleanup + this.boundHandleNotifications = this.handleNotifications.bind(this) + + // Listen for custom HTMX notification events on document.body + // Events from HX-Trigger response headers bubble up to document.body + document.body.addEventListener('showNotifications', this.boundHandleNotifications) } disconnect() { - document.removeEventListener('showNotifications', this.handleNotifications.bind(this)) + document.body.removeEventListener('showNotifications', this.boundHandleNotifications) } handleNotifications(event) { - const notifications = event.detail?.notifications || [] + // HTMX wraps the trigger value in an object with 'value' and 'elt' properties + // event.detail.value contains the actual array from the HX-Trigger header + const notifications = event.detail?.value || [] notifications.forEach(notification => { const className = this.getClassForType(notification.type) diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index a6bd1e2..d57ee03 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -60,7 +60,33 @@ - `TravelDataService` - API integration and caching - `ParticipantCardDataService` - Card display data - `InsuranceService` - Consolidated insurance operations (eligibility, type filtering, reassignment) with request-scoped caching -- `RoomAssignmentService` - Automatic room assignment +- `RoomAssignmentService` - Automatic room assignment with intelligent conflict resolution + +### Room Reassignment with Conflict Resolution +The room assignment system allows participants to freely select any room from Step 1 selections, with automatic conflict resolution when capacity is exceeded. + +**Key Features:** +- **Flexible Selection**: All rooms from Step 1 always appear in participant dropdown, regardless of current capacity +- **Auto-Assignment**: When exactly ONE room type selected, all participants auto-assigned and dropdown disabled +- **Conflict Resolution**: When participant selects room at capacity, system automatically unassigns minimum participants needed +- **Unassignment Priority**: Highest index participants unassigned first (keeps applicant and early participants stable) +- **User Notifications**: Unassigned participants receive warning notifications via toast system + +**Implementation:** +- `ParticipantRoomChoiceLoader`: Always includes all selected rooms (no capacity filtering) +- `ParticipantAssignedRoomFieldHandler`: Detects conflicts and resolves via auto-unassignment + - `detectRoomCapacityConflict()`: Calculates if assignment would exceed capacity + - `resolveRoomCapacityConflict()`: Unassigns minimum participants (highest index first) + - Generates notifications for unassigned participants only (not for user-initiated assignment) +- `RoomAssignmentService`: Unchanged - still auto-assigns when single room type selected +- `ParticipantFieldOptionsProvider`: Unchanged - disables dropdown when single room type (auto-assigned) + +**Example Scenario:** +- User selects 1x "Doppelzimmer" (capacity 2), 3 participants +- Participants 0 and 1 auto-assigned to "Doppelzimmer" +- Participant 2 manually selects "Doppelzimmer" (at capacity) +- System automatically unassigns Participant 1 (highest index) +- Participant 2 gets assigned, Participant 1 receives warning notification ## Critical Patterns diff --git a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php index 54d8ca3..60fa731 100644 --- a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php +++ b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php @@ -6,9 +6,6 @@ namespace App\Controller\Booking\Traits; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; -use App\Service\BookingPriceCalculatorService; -use App\Service\BookingService; -use App\Service\ParticipantCardDataService; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -137,18 +134,20 @@ trait ParticipantCardFlowTrait $form->handleRequest($request); - // Save updated booking data to session - $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); - - // Collect notifications from participant DTO - $participant = $bookingDto->participants[$index] ?? null; - $notifications = $participant?->notifications ?? []; - - // Clear notifications after collecting - if (null !== $participant) { - $participant->notifications = []; + // Collect notifications from ALL participants (not just current one) + // This is important for auto-unassignment scenarios where other participants + // may receive notifications when the current participant takes an action + $notifications = []; + foreach ($bookingDto->participants as $participant) { + if (false === empty($participant->notifications)) { + $notifications = array_merge($notifications, $participant->notifications); + $participant->notifications = []; + } } + // Save updated booking data to session (after collecting & clearing notifications) + $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); + // Calculate summary data for sidebar $summaryData = $this->calculateSummaryData($bookingDto); diff --git a/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php b/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php index 4119494..f6a1a6a 100644 --- a/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php +++ b/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Form\ChoiceLoader; -use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use Symfony\Component\Form\ChoiceList\ChoiceListInterface; use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; @@ -13,25 +12,21 @@ use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; /** * Choice loader for participant room assignments. * - * Generates room choices for each participant based on availability, - * current assignments, and room capacity constraints. This loader - * ensures participants can only select rooms with available capacity - * while maintaining their current assignment if applicable. + * Generates room choices for each participant from all selected room types + * in Step 1, regardless of current capacity or assignments. Capacity conflicts + * are automatically resolved by the field handler through intelligent + * auto-unassignment when needed. */ class ParticipantRoomChoiceLoader implements ChoiceLoaderInterface { private ?ChoiceListInterface $choiceList = null; /** - * @param ParticipantDto[] $allParticipants all participant DTOs from the root form - * @param RoomSelectionDto[] $selectedRooms the rooms selected in the previous step - * @param int $participantIndex the index of the current participant + * @param RoomSelectionDto[] $selectedRooms the rooms selected in the previous step */ public function __construct( private readonly ChoiceListFactoryInterface $factory, - private readonly array $allParticipants, private readonly array $selectedRooms, - private readonly int $participantIndex, ) { } @@ -66,50 +61,20 @@ class ParticipantRoomChoiceLoader implements ChoiceLoaderInterface /** * Generates room choices for the specific participant. * + * Always includes all selected rooms from Step 1 regardless of current capacity. + * Capacity conflicts are handled by the field handler during assignment. + * * @return array */ private function generateRoomChoicesForParticipant(): array { - $roomOccupancy = $this->calculateRoomOccupancy(); $participantRoomChoices = []; - $assignedRoomId = $this->allParticipants[$this->participantIndex]->assignedRoomId ?? null; foreach ($this->selectedRooms as $roomSelection) { - $currentOccupancy = $roomOccupancy[$roomSelection->roomId] ?? 0; - - // If this participant is already assigned to this room, exclude them from occupancy count - $adjustedOccupancy = $currentOccupancy; - if ($assignedRoomId === $roomSelection->roomId) { - --$adjustedOccupancy; - } - - $totalCapacity = $roomSelection->quantity * $roomSelection->capacity; - $remainingCapacity = $totalCapacity - $adjustedOccupancy; - - // Include room if it has capacity OR if it's the participant's current assignment - if ($remainingCapacity > 0 || $assignedRoomId === $roomSelection->roomId) { - $participantRoomChoices[$roomSelection->roomLabel] = $roomSelection->roomId; - } + // Always include all selected rooms - capacity conflicts handled in field handler + $participantRoomChoices[$roomSelection->roomLabel] = $roomSelection->roomId; } return $participantRoomChoices; } - - /** - * Calculates room occupancy based on all participant assignments. - * - * @return array - */ - private function calculateRoomOccupancy(): array - { - $roomOccupancy = []; - - foreach ($this->allParticipants as $participant) { - if (null !== $participant->assignedRoomId) { - $roomOccupancy[$participant->assignedRoomId] = ($roomOccupancy[$participant->assignedRoomId] ?? 0) + 1; - } - } - - return $roomOccupancy; - } } diff --git a/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php b/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php index b18a938..8009dcd 100644 --- a/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php +++ b/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php @@ -6,7 +6,6 @@ namespace App\Form\Service\Factory; use App\BusProNet\Model\Room; use App\Form\ChoiceLoader\ParticipantRoomChoiceLoader; -use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; @@ -29,20 +28,13 @@ class ParticipantRoomChoiceLoaderFactory * Uses room selections from step 1 (RoomSelectionDto objects) to generate * available room choices for participants. * - * @param ParticipantDto[] $allParticipants all participants from the booking - * @param RoomSelectionDto[] $selectedRooms rooms selected in create flow step 1 - * @param int $participantIndex index of the participant needing room choices + * @param RoomSelectionDto[] $selectedRooms rooms selected in create flow step 1 */ - public function createForCreate( - array $allParticipants, - array $selectedRooms, - int $participantIndex, - ): ParticipantRoomChoiceLoader { + public function createForCreate(array $selectedRooms): ParticipantRoomChoiceLoader + { return new ParticipantRoomChoiceLoader( $this->choiceListFactory, - $allParticipants, - $selectedRooms, - $participantIndex + $selectedRooms ); } @@ -53,23 +45,16 @@ class ParticipantRoomChoiceLoaderFactory * room choices. Participants can only be reassigned within the room types * that have already been booked. * - * @param ParticipantDto[] $allParticipants all participants from the booking - * @param Room[] $bookedRooms rooms from the existing booking - * @param int $participantIndex index of the participant needing room choices + * @param Room[] $bookedRooms rooms from the existing booking */ - public function createForEdit( - array $allParticipants, - array $bookedRooms, - int $participantIndex, - ): ParticipantRoomChoiceLoader { + public function createForEdit(array $bookedRooms): ParticipantRoomChoiceLoader + { // Convert Booking Room objects to RoomSelectionDto format for the choice loader $roomSelections = $this->convertBookedRoomsToSelections($bookedRooms); return new ParticipantRoomChoiceLoader( $this->choiceListFactory, - $allParticipants, - $roomSelections, - $participantIndex + $roomSelections ); } diff --git a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php index 76d38d2..f9e9180 100644 --- a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -19,8 +19,17 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler; * - Extracts room ID from submitted form data * - Converts empty strings to null (unselected choice) * - Normalizes string values to integers + * - Detects capacity conflicts when assigning rooms + * - Automatically unassigns minimum number of participants to resolve conflicts + * - Generates notifications for unassigned participants * - Updates the participant's assignedRoomId property * + * Conflict Resolution Strategy: + * - Allows participants to select any room from Step 1 selections + * - When assignment would exceed capacity, automatically unassigns others + * - Unassignment priority: highest index participants first (keeps applicant stable) + * - Only unassigns minimum number needed to make space + * * Dependencies: None (this is a base field that other handlers may depend on) */ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandler @@ -50,10 +59,12 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle * 1. Safely retrieves the participant object from the DTO * 2. Extracts the assignedRoomId value from submitted data * 3. Normalizes the value (empty string → null, numeric string → integer) - * 4. Updates the participant's assignedRoomId property + * 4. Detects capacity conflicts when assigning to room + * 5. Resolves conflicts by auto-unassigning minimum participants needed + * 6. Updates the participant's assignedRoomId property * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The index of the participant being processed */ public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void @@ -69,6 +80,160 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle $roomId = $this->getFieldValue($submittedData, $this->getFieldName()); // Convert form string to integer, handling empty selections as null - $participant->assignedRoomId = $this->normalizeIntValue($roomId); + $normalizedRoomId = $this->normalizeIntValue($roomId); + + // If assigning to a room (not unselecting), check for capacity conflicts + if (null !== $normalizedRoomId && $this->detectRoomCapacityConflict($normalizedRoomId, $bookingDto, $participantIndex)) { + // Resolve conflict by auto-unassigning other participants + $this->resolveRoomCapacityConflict($normalizedRoomId, $bookingDto, $participantIndex); + } + + // Assign the room to the current participant + $participant->assignedRoomId = $normalizedRoomId; + } + + /** + * Detects if assigning the participant to a room would exceed capacity. + * + * Calculates current occupancy for the room (excluding the current participant + * if they're already assigned to it) and compares against total capacity. + * Returns true if the assignment would cause a capacity conflict. + * + * @param int $roomId The room ID being assigned + * @param BookingDto $bookingDto The booking DTO with all participants + * @param int $currentParticipantIndex The participant being assigned + * + * @return bool True if assignment would exceed capacity + */ + private function detectRoomCapacityConflict(int $roomId, BookingDto $bookingDto, int $currentParticipantIndex): bool + { + // Get the room selection details + $roomSelection = null; + foreach ($bookingDto->roomSelections as $selection) { + if ($selection->roomId === $roomId) { + $roomSelection = $selection; + break; + } + } + + // If room not found in selections, no conflict (shouldn't happen) + if (null === $roomSelection) { + return false; + } + + // Calculate total capacity for this room type + $totalCapacity = $roomSelection->quantity * $roomSelection->capacity; + + // Count current occupancy (excluding current participant) + $occupancy = 0; + foreach ($bookingDto->participants as $index => $participant) { + if ($index !== $currentParticipantIndex && $participant->assignedRoomId === $roomId) { + ++$occupancy; + } + } + + // Conflict if assigning would exceed capacity + return ($occupancy + 1) > $totalCapacity; + } + + /** + * Resolves room capacity conflicts by auto-unassigning participants. + * + * When a participant is assigned to a room at capacity, this method + * automatically unassigns the minimum number of other participants + * needed to make space. Unassignment priority is highest index first + * (keeps applicant and early participants stable). + * + * Generates warning notifications for unassigned participants. + * + * @param int $roomId The room ID being assigned + * @param BookingDto $bookingDto The booking DTO with all participants + * @param int $currentParticipantIndex The participant being assigned + */ + private function resolveRoomCapacityConflict(int $roomId, BookingDto $bookingDto, int $currentParticipantIndex): void + { + // Get room label for notification message + $roomLabel = $this->getRoomLabelById($roomId, $bookingDto); + + // Get participants currently assigned to this room (excluding current participant) + $participantsWithRoom = $this->getParticipantsAssignedToRoom($roomId, $bookingDto, $currentParticipantIndex); + + // Sort by index descending (highest index first) + krsort($participantsWithRoom); + + // Calculate how many need to be unassigned + $roomSelection = null; + foreach ($bookingDto->roomSelections as $selection) { + if ($selection->roomId === $roomId) { + $roomSelection = $selection; + break; + } + } + + if (null === $roomSelection) { + return; // Shouldn't happen + } + + $totalCapacity = $roomSelection->quantity * $roomSelection->capacity; + $currentOccupancy = count($participantsWithRoom); + $spacesNeeded = ($currentOccupancy + 1) - $totalCapacity; + + // Unassign minimum participants needed (typically 1) + $unassignedCount = 0; + foreach ($participantsWithRoom as $index => $participant) { + if ($unassignedCount >= $spacesNeeded) { + break; + } + + // Unassign participant + $participant->assignedRoomId = null; + + // Add notification for unassigned participant + $participantLabel = $participant->isApplicant() ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1); + $participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel)); + + ++$unassignedCount; + } + } + + /** + * Gets all participants assigned to a specific room. + * + * @param int $roomId The room ID to search for + * @param BookingDto $bookingDto The booking DTO with all participants + * @param int $excludeParticipantIndex Participant index to exclude from results + * + * @return array Array of participants indexed by their position + */ + private function getParticipantsAssignedToRoom(int $roomId, BookingDto $bookingDto, int $excludeParticipantIndex): array + { + $participantsWithRoom = []; + + foreach ($bookingDto->participants as $index => $participant) { + if ($index !== $excludeParticipantIndex && $participant->assignedRoomId === $roomId) { + $participantsWithRoom[$index] = $participant; + } + } + + return $participantsWithRoom; + } + + /** + * Gets the room label for a given room ID. + * + * @param int $roomId The room ID to look up + * @param BookingDto $bookingDto The booking DTO with room selections + * + * @return string The room label or 'Unbekannt' if not found + */ + private function getRoomLabelById(int $roomId, BookingDto $bookingDto): string + { + foreach ($bookingDto->roomSelections as $selection) { + if ($selection->roomId === $roomId) { + return $selection->roomLabel; + } + } + + return 'Unbekannt'; } } diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 338be0f..ede849a 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -88,18 +88,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { // Create context: use selected rooms from step 1 $choiceLoader = $this->roomChoiceLoaderFactory->createForCreate( - $bookingDto->participants, - $bookingDto->getSelectedRooms(), - $participantIndex + $bookingDto->getSelectedRooms() ); // Disable when only one room type selected (auto-assigned) $disabled = 1 === count($bookingDto->getSelectedRooms()); } elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) { // Edit context: use already-booked rooms from booking $choiceLoader = $this->roomChoiceLoaderFactory->createForEdit( - $bookingDto->participants, - $bookingDto->booking->rooms, - $participantIndex + $bookingDto->booking->rooms ); // Disable when only one room in booking (no reassignment needed) $disabled = 1 === count($bookingDto->booking->rooms); diff --git a/tests/Form/Service/ParticipantAssignedRoomFieldHandlerTest.php b/tests/Form/Service/ParticipantAssignedRoomFieldHandlerTest.php new file mode 100644 index 0000000..b7b8305 --- /dev/null +++ b/tests/Form/Service/ParticipantAssignedRoomFieldHandlerTest.php @@ -0,0 +1,339 @@ +handler = new ParticipantAssignedRoomFieldHandler(); + } + + public function testGetFieldName(): void + { + $this->assertSame('assignedRoomId', $this->handler->getFieldName()); + } + + public function testGetDependencies(): void + { + $this->assertSame([], $this->handler->getDependencies()); + } + + public function testShouldProcessReturnsTrueWhenFieldPresent(): void + { + $this->assertTrue($this->handler->shouldProcess(['assignedRoomId' => '1'], BookingDto::MODE_CREATE, 0)); + $this->assertTrue($this->handler->shouldProcess(['assignedRoomId' => ''], BookingDto::MODE_EDIT, 5)); + } + + public function testShouldProcessReturnsFalseWhenFieldNotPresent(): void + { + $this->assertFalse($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0)); + $this->assertFalse($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5)); + } + + public function testProcessFieldWithoutParticipant(): void + { + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $submittedData = ['assignedRoomId' => '1']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + // Should handle gracefully when participant doesn't exist + $this->expectNotToPerformAssertions(); + } + + public function testProcessFieldAssignsRoomWithAvailableCapacity(): void + { + $participant = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 1)]; + + $submittedData = ['assignedRoomId' => '1']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertSame(1, $participant->assignedRoomId); + } + + public function testProcessFieldUnassignsParticipantWhenRoomIdIsEmpty(): void + { + $participant = new ParticipantDto(); + $participant->assignedRoomId = 1; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $submittedData = ['assignedRoomId' => '']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertNull($participant->assignedRoomId); + } + + public function testProcessFieldUnassignsParticipantWhenRoomIdIsNull(): void + { + $participant = new ParticipantDto(); + $participant->assignedRoomId = 1; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $submittedData = ['assignedRoomId' => null]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertNull($participant->assignedRoomId); + } + + public function testProcessFieldUnassignsHighestIndexParticipantWhenRoomAtCapacity(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + $participant2->assignedRoomId = null; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2]; + // 1x "Doppelzimmer" (capacity 2) - room is at capacity with participants 0 and 1 + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 1)]; + + $submittedData = ['assignedRoomId' => '1']; + + // Participant 2 tries to select room 1 + $this->handler->processField($submittedData, $bookingDto, 2); + + // Participant 1 (highest index with room 1) should be unassigned + $this->assertSame(1, $participant0->assignedRoomId); // Still assigned (applicant protection) + $this->assertNull($participant1->assignedRoomId); // Unassigned + $this->assertSame(1, $participant2->assignedRoomId); // Newly assigned + } + + public function testProcessFieldGeneratesWarningNotificationForUnassignedParticipant(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2]; + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 1)]; + + $submittedData = ['assignedRoomId' => '1']; + + $this->handler->processField($submittedData, $bookingDto, 2); + + // Participant 1 should have a warning notification + $notifications = $participant1->notifications; + $this->assertCount(1, $notifications); + $this->assertSame('warning', $notifications[0]['type']); + $this->assertStringContainsString('Doppelzimmer', $notifications[0]['message']); + $this->assertStringContainsString('wurde von', $notifications[0]['message']); + $this->assertStringContainsString('entfernt', $notifications[0]['message']); + } + + public function testProcessFieldDoesNotGenerateNotificationForAssignedParticipant(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2]; + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 1)]; + + $submittedData = ['assignedRoomId' => '1']; + + $this->handler->processField($submittedData, $bookingDto, 2); + + // Participant 2 (the one being assigned) should NOT have a notification (user-initiated action) + $this->assertEmpty($participant2->notifications); + } + + public function testProcessFieldHandlesRoomChangeWithoutConflict(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1]; + // 2x "Doppelzimmer" (capacity 2 each = 4 total beds) + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 2)]; + + $submittedData = ['assignedRoomId' => '1']; + + // Participant 0 changes room (still room 1, but should not trigger conflict) + $this->handler->processField($submittedData, $bookingDto, 0); + + // Both should remain assigned (participant 0's own slot was freed, so no conflict) + $this->assertSame(1, $participant0->assignedRoomId); + $this->assertSame(1, $participant1->assignedRoomId); + $this->assertEmpty($participant1->notifications); + } + + public function testProcessFieldUnassignsMultipleParticipantsWhenNeeded(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + $participant2->assignedRoomId = 1; + + $participant3 = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2, $participant3]; + // 1x "Einzelzimmer" (capacity 1) - only 1 bed available + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Einzelzimmer', 1, 1)]; + + $submittedData = ['assignedRoomId' => '1']; + + // Participant 3 tries to select room 1 + $this->handler->processField($submittedData, $bookingDto, 3); + + // All three participants with room 1 should be unassigned (highest indices first: 2, 1, 0) + // But we only need to unassign enough to make space (3 participants, only 1 space needed) + $this->assertNull($participant2->assignedRoomId); // Highest index, unassigned first + $this->assertNull($participant1->assignedRoomId); // Second highest, unassigned second + $this->assertNull($participant0->assignedRoomId); // Third highest, unassigned third + $this->assertSame(1, $participant3->assignedRoomId); // Newly assigned + } + + public function testProcessFieldHandlesNoConflictWithMultipleRoomQuantities(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + $participant2->assignedRoomId = 1; + + $participant3 = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2, $participant3]; + // 2x "Doppelzimmer" (capacity 2 each = 4 total beds) + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 2)]; + + $submittedData = ['assignedRoomId' => '1']; + + // Participant 3 tries to select room 1 + $this->handler->processField($submittedData, $bookingDto, 3); + + // All should remain assigned (4 beds available, 4 participants) + $this->assertSame(1, $participant0->assignedRoomId); + $this->assertSame(1, $participant1->assignedRoomId); + $this->assertSame(1, $participant2->assignedRoomId); + $this->assertSame(1, $participant3->assignedRoomId); + $this->assertEmpty($participant0->notifications); + $this->assertEmpty($participant1->notifications); + $this->assertEmpty($participant2->notifications); + } + + public function testProcessFieldHandlesComplexScenarioWithMultipleRoomTypes(): void + { + $participant0 = new ParticipantDto(); + $participant0->assignedRoomId = 1; // Doppelzimmer + + $participant1 = new ParticipantDto(); + $participant1->assignedRoomId = 1; // Doppelzimmer + + $participant2 = new ParticipantDto(); + $participant2->assignedRoomId = 2; // Einzelzimmer + + $participant3 = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant0, $participant1, $participant2, $participant3]; + $bookingDto->roomSelections = [ + $this->createRoomSelection(1, 'Doppelzimmer', 2, 1), // 1x Doppelzimmer (capacity 2) + $this->createRoomSelection(2, 'Einzelzimmer', 1, 1), // 1x Einzelzimmer (capacity 1) + ]; + + $submittedData = ['assignedRoomId' => '1']; + + // Participant 3 tries to select Doppelzimmer (already at capacity with 0 and 1) + $this->handler->processField($submittedData, $bookingDto, 3); + + // Participant 1 (highest index with room 1) should be unassigned + $this->assertSame(1, $participant0->assignedRoomId); + $this->assertNull($participant1->assignedRoomId); // Unassigned + $this->assertSame(2, $participant2->assignedRoomId); // Still in Einzelzimmer + $this->assertSame(1, $participant3->assignedRoomId); // Newly assigned to Doppelzimmer + } + + public function testProcessFieldHandlesEdgeCaseWhenRoomNotFoundInSelections(): void + { + $participant = new ParticipantDto(); + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + $bookingDto->roomSelections = [$this->createRoomSelection(1, 'Doppelzimmer', 2, 1)]; + + // Try to assign room 999 (not in selections) + $submittedData = ['assignedRoomId' => '999']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + // Should still assign (no conflict detection possible, but assignment proceeds) + $this->assertSame(999, $participant->assignedRoomId); + } + + /** + * Helper method to create a RoomSelectionDto for testing. + */ + private function createRoomSelection(int $roomId, string $label, int $capacity, int $quantity): RoomSelectionDto + { + $selection = new RoomSelectionDto(); + $selection->roomId = $roomId; + $selection->roomLabel = $label; + $selection->capacity = $capacity; + $selection->quantity = $quantity; + + return $selection; + } +}