feat: unassign rooms from other participants when unavailable
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,17 +134,19 @@ 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) {
|
||||
// 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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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<string, int>
|
||||
*/
|
||||
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) {
|
||||
// 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<int, int>
|
||||
*/
|
||||
private function calculateRoomOccupancy(): array
|
||||
{
|
||||
$roomOccupancy = [];
|
||||
|
||||
foreach ($this->allParticipants as $participant) {
|
||||
if (null !== $participant->assignedRoomId) {
|
||||
$roomOccupancy[$participant->assignedRoomId] = ($roomOccupancy[$participant->assignedRoomId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $roomOccupancy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +59,9 @@ 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<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
|
||||
@@ -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<int, \App\Form\Model\ParticipantDto> 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Form\Service\ParticipantAssignedRoomFieldHandler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ParticipantAssignedRoomFieldHandlerTest extends TestCase
|
||||
{
|
||||
private ParticipantAssignedRoomFieldHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user