feat: unassign rooms from other participants when unavailable

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent 6cc5886073
commit 647fa8e212
8 changed files with 576 additions and 95 deletions
@@ -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);
@@ -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<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) {
$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<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
* @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
);
}
@@ -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<string, mixed> $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<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);