wip: booking process, refactor participant room assignment logic

This commit is contained in:
Björn Fromme
2025-07-22 08:52:13 +02:00
parent b3c633dccb
commit 2f5b855bae
16 changed files with 216 additions and 94 deletions
@@ -336,7 +336,7 @@ class BookingDataProcessor
{
foreach ($bookingData->participants as $index => $participant) {
$payload['teilnehmerliste']['teilnehmer'][] = [
'@id' => $index,
'@id' => $index + 1,
'status' => $bookingData->participantsStatus[$index],
...$participant->toPayload(),
];
@@ -358,7 +358,7 @@ class BookingDataProcessor
$payload['zusatzleistungen']['zusatzleistung'][] = [
'@idleistung' => $service->id,
'@anzahl' => count($service->mapping),
'@zuordnung' => implode(',', $service->mapping),
'@zuordnung' => implode(',', array_map(fn($index) => $index + 1, $service->mapping)),
];
}
@@ -366,7 +366,7 @@ class BookingDataProcessor
$payload['beförderungen']['beförderung'][] = [
'@idleistung' => $service->id,
'@anzahl' => count($service->mapping),
'@zuordnung' => implode(',', $service->mapping),
'@zuordnung' => implode(',', array_map(fn($index) => $index + 1, $service->mapping)),
];
}
@@ -378,7 +378,7 @@ class BookingDataProcessor
'@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null,
'@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null,
'@anzahl' => $room->totalCount,
'@zuordnung' => implode(',', $room->mapping),
'@zuordnung' => implode(',', array_map(fn($index) => $index + 1, $room->mapping)),
];
}
}
@@ -399,7 +399,7 @@ class BookingDataProcessor
$payload['zustiege']['zustieg'][] = [
'@idzustieg' => $pickup->id,
'@anzahl' => count($pickup->mapping),
'@zuordnung' => implode(',', $pickup->mapping),
'@zuordnung' => implode(',', array_map(fn($index) => $index + 1, $pickup->mapping)),
];
}
}
+7 -3
View File
@@ -187,20 +187,24 @@ class Booking
// Sum up all services
foreach ([...$this->transportationServices, ...$this->additionalServices] as $service) {
if (in_array($participantIndex, $service->mapping) || true === $service->mandatory) {
if ((in_array($participantIndex, $service->mapping) || true === $service->mandatory)
&& isset($service->individualPrice[$participantIndex])) {
$price += $service->individualPrice[$participantIndex];
}
}
// Sum up all surcharges
foreach ($this->surcharges as $surcharge) {
if (in_array($participantIndex, $surcharge->mapping)) {
if (in_array($participantIndex, $surcharge->mapping)
&& isset($surcharge->individualPrice[$participantIndex])) {
$price += $surcharge->individualPrice[$participantIndex];
}
}
$room = $this->getRoomForParticipant($participantIndex);
$price += $room->individualPrice[$participantIndex];
if (null !== $room && isset($room->individualPrice[$participantIndex])) {
$price += $room->individualPrice[$participantIndex];
}
return $price;
}
+2 -2
View File
@@ -49,7 +49,7 @@ class BookingParser extends AbstractParser
$booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder'));
$participantsStatus = $this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/');
$booking->participantsStatus = array_combine(range(1, count($participantsStatus)), $participantsStatus);
$booking->participantsStatus = array_combine(range(0, count($participantsStatus) - 1), $participantsStatus);
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
@@ -110,7 +110,7 @@ class BookingParser extends AbstractParser
$node->each(function (Crawler $node) use (&$participants) {
$id = (int) $node->attr('id');
$participants[$id] = $this->parsePersonalData($node);
$participants[$id - 1] = $this->parsePersonalData($node);
});
return $participants;
+1 -1
View File
@@ -26,7 +26,7 @@ class PickupsParser extends AbstractParser
$pickup->time = $this->stringToDateTime($pickupDate.' '.$pickupTime);
$pickup->price = $price;
$mapping = $this->stringToArray($node->attr('zuordnung'));
$pickup->mapping = array_map('intval', $mapping);
$pickup->mapping = array_map(function ($index) { return (int) $index - 1; }, $mapping);
$pickups[$pickupId] = $pickup;
});
+2 -2
View File
@@ -24,14 +24,14 @@ class RoomsParser extends AbstractParser
$room->maxPax = (int) $node->attr('maxpax');
$room->board = $node->attr('verpflegung');
$mapping = $this->stringToArray($node->attr('zuordnung'));
$room->mapping = array_map('intval', $mapping);
$room->mapping = array_map(function ($index) { return (int) $index - 1; }, $mapping);
$room->totalPrice = $this->stringToFloat($node->attr('gesamtpreis'));
$individualPrices = array_map(
function ($price) {
return $this->stringToFloat($price);
}, $this->stringToArray($node->attr('einzelpreis', ''), '/')
);
$room->individualPrice = array_combine($mapping, $individualPrices);
$room->individualPrice = array_combine($room->mapping, $individualPrices);
$rooms[$room->id] = $room;
});
+2 -2
View File
@@ -23,14 +23,14 @@ class ServicesParser extends AbstractParser
$service->subType = $node->attr('unterart');
$service->totalCount = $node->attr('anzahl') ? (int) $node->attr('anzahl') : null;
$mapping = $this->stringToArray($node->attr('zuordnung'));
$service->mapping = array_map('intval', $mapping);
$service->mapping = array_map(function ($index) { return (int) $index - 1; }, $mapping);
$service->totalPrice = $this->stringToFloat($node->attr('gesamtpreis'));
$individualPrices = array_map(
function ($price) {
return $this->stringToFloat($price);
}, $this->stringToArray($node->attr('einzelpreis', ''), '/')
);
$service->individualPrice = array_combine($mapping, $individualPrices);
$service->individualPrice = array_combine($service->mapping, $individualPrices);
if (Constants::CATEGORY_TRANSPORTATION === $category) {
$service->direction = $node->attr('richtung');
}
+2 -2
View File
@@ -17,13 +17,13 @@ class SurchargesParser extends AbstractParser
$surcharge->label = empty($surchargeLabel) ? 'unbekannt' : $surchargeLabel;
$surcharge->totalPrice = $this->stringToFloat($node->attr('gesamtpreis'));
$mapping = $this->stringToArray($node->attr('zuordnung'));
$surcharge->mapping = array_map('intval', $mapping);
$surcharge->mapping = array_map(function ($index) { return (int) $index - 1; }, $mapping);
$individualPrices = array_map(
function ($price) {
return $this->stringToFloat($price);
}, $this->stringToArray($node->attr('einzelpreis', ''), '/')
);
$surcharge->individualPrice = array_combine($mapping, $individualPrices);
$surcharge->individualPrice = array_combine($surcharge->mapping, $individualPrices);
$surcharges[] = $surcharge;
});
+15 -2
View File
@@ -4,6 +4,7 @@ namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantRoomChoiceLoaderFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -16,6 +17,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateParticipantType extends AbstractType
{
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $choiceLoaderFactory,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
@@ -58,10 +64,16 @@ class BookingCreateParticipantType extends AbstractType
$data = $event->getData();
$form = $event->getForm();
$choiceLoader = $this->choiceLoaderFactory->createChoiceLoader(
$options['participant_adapter'],
$options['selected_rooms'],
$data->index
);
$form->add('assignedRoomId', ChoiceType::class, [
'label' => 'Zimmer',
'placeholder' => 'Bitte wählen',
'choices' => $options['room_choices'][$data->index],
'choice_loader' => $choiceLoader,
]);
})
;
@@ -71,7 +83,8 @@ class BookingCreateParticipantType extends AbstractType
{
$resolver->setDefaults([
'data_class' => ParticipantDto::class,
'room_choices' => [],
'participant_adapter' => null,
'selected_rooms' => [],
]);
}
}
+6 -65
View File
@@ -27,8 +27,7 @@ class BookingCreateStep2Type extends AbstractType
$form = $event->getForm();
$adapter = new ParticipantDataAdapter($data->participants);
$roomChoices = $this->generateRoomChoices($adapter, $data->getSelectedRooms());
$this->addParticipantsField($form, $roomChoices);
$this->addParticipantsField($form, $adapter, $data->getSelectedRooms());
}
public function onPreSubmit(FormEvent $event): void
@@ -39,73 +38,14 @@ class BookingCreateStep2Type extends AbstractType
$bookingCreateDto = $form->getData();
$adapter = new ParticipantDataAdapter($data['participants']);
$roomChoices = $this->generateRoomChoices($adapter, $bookingCreateDto->getSelectedRooms());
$form->remove('participants');
$this->addParticipantsField($form, $roomChoices);
$this->addParticipantsField($form, $adapter, $bookingCreateDto->getSelectedRooms());
}
/**
* Calculates room occupancy based on participant assignments.
*
* @return array<int, int>
* Adds the participants collection field to the form.
*/
private function calculateRoomOccupancy(ParticipantDataAdapter $adapter): array
{
$roomOccupancy = [];
foreach ($adapter->getParticipants() as $participant) {
$assignedRoomId = $adapter->getAssignedRoomId($participant);
if (null !== $assignedRoomId) {
$roomOccupancy[$assignedRoomId] = ($roomOccupancy[$assignedRoomId] ?? 0) + 1;
}
}
return $roomOccupancy;
}
/**
* Generates room choices for each participant based on availability and current assignments.
*
* @return array<int, array<string, int>>
*/
private function generateRoomChoices(ParticipantDataAdapter $adapter, array $selectedRooms): array
{
$roomOccupancy = $this->calculateRoomOccupancy($adapter);
$roomChoices = [];
foreach ($adapter->getParticipants() as $index => $participant) {
$participantRoomChoices = [];
$assignedRoomId = $adapter->getAssignedRoomId($participant);
foreach ($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;
}
$remainingCapacity = $roomSelection->minPax - $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;
}
}
$roomChoices[$index] = $participantRoomChoices;
}
return $roomChoices;
}
/**
* Adds the participants collection field to the form with the given room choices.
*
* @param array<int, array<string, int>> $roomChoices
*/
private function addParticipantsField(FormInterface $form, array $roomChoices): void
private function addParticipantsField(FormInterface $form, ParticipantDataAdapter $adapter, array $selectedRooms): void
{
$form->add('participants', CollectionType::class, [
'entry_type' => BookingCreateParticipantType::class,
@@ -113,7 +53,8 @@ class BookingCreateStep2Type extends AbstractType
'allow_delete' => false,
'by_reference' => false,
'entry_options' => [
'room_choices' => $roomChoices,
'participant_adapter' => $adapter,
'selected_rooms' => $selectedRooms,
],
]);
}
@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BoolingEditParticipantType extends AbstractType
class BookingEditParticipantType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
@@ -34,7 +34,7 @@ class BoolingEditParticipantType extends AbstractType
$form = $event->getForm();
$personalDataMutable = $options['personal_data_mutable'] && $participantData->mutable;
$isApplicant = 1 === $participantIndex;
$isApplicant = 0 === $participantIndex;
$form
->add('firstName', TextType::class, [
+1 -1
View File
@@ -23,7 +23,7 @@ final class BookingEditType extends AbstractType
$travelData = $data->travel;
$form->add('participants', CollectionType::class, [
'entry_type' => BoolingEditParticipantType::class,
'entry_type' => BookingEditParticipantType::class,
'entry_options' => [
'selectable_courses' => $this->mergeSelectableServices($data, Constants::TOKEN_COURSES),
'selectable_ski_passes' => $this->mergeSelectableServices($data, Constants::TOKEN_SKI_PASS),
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Form\ChoiceLoader;
use App\Form\DataAdapters\ParticipantDataAdapter;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface;
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.
*/
class ParticipantRoomChoiceLoader implements ChoiceLoaderInterface
{
private ?ChoiceListInterface $choiceList = null;
public function __construct(
private readonly ChoiceListFactoryInterface $factory,
private readonly ParticipantDataAdapter $adapter,
private readonly array $selectedRooms,
private readonly int $participantIndex,
) {
}
public function loadChoiceList(?callable $value = null): ChoiceListInterface
{
if (null === $this->choiceList) {
$choices = $this->generateRoomChoicesForParticipant();
$this->choiceList = $this->factory->createListFromChoices($choices, $value);
}
return $this->choiceList;
}
public function loadChoicesForValues(array $values, ?callable $value = null): array
{
if (empty($values)) {
return [];
}
return $this->loadChoiceList($value)->getChoicesForValues($values);
}
public function loadValuesForChoices(array $choices, ?callable $value = null): array
{
if (empty($choices)) {
return [];
}
return $this->loadChoiceList($value)->getValuesForChoices($choices);
}
/**
* Generates room choices for the specific participant.
*
* @return array<string, int>
*/
private function generateRoomChoicesForParticipant(): array
{
$roomOccupancy = $this->calculateRoomOccupancy();
$participantRoomChoices = [];
$assignedRoomId = $this->getParticipantAssignedRoomId();
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;
}
$remainingCapacity = $roomSelection->minPax - $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;
}
}
return $participantRoomChoices;
}
/**
* Calculates room occupancy based on participant assignments.
*
* @return array<int, int>
*/
private function calculateRoomOccupancy(): array
{
$roomOccupancy = [];
foreach ($this->adapter->getParticipants() as $participant) {
$assignedRoomId = $this->adapter->getAssignedRoomId($participant);
if (null !== $assignedRoomId) {
$roomOccupancy[$assignedRoomId] = ($roomOccupancy[$assignedRoomId] ?? 0) + 1;
}
}
return $roomOccupancy;
}
/**
* Gets the assigned room ID for the current participant.
*/
private function getParticipantAssignedRoomId(): ?int
{
$participants = $this->adapter->getParticipants();
$participant = $participants[$this->participantIndex] ?? null;
return null !== $participant ? $this->adapter->getAssignedRoomId($participant) : null;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\ChoiceLoader\ParticipantRoomChoiceLoader;
use App\Form\DataAdapters\ParticipantDataAdapter;
use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface;
/**
* Factory service for creating ParticipantRoomChoiceLoader instances.
*
* This service provides a clean way to create choice loaders with proper
* dependency injection instead of manually instantiating factories in form types.
*/
class ParticipantRoomChoiceLoaderFactory
{
public function __construct(
private readonly ChoiceListFactoryInterface $choiceListFactory,
) {
}
/**
* Creates a ParticipantRoomChoiceLoader for the given participant and room data.
*/
public function createChoiceLoader(
ParticipantDataAdapter $adapter,
array $selectedRooms,
int $participantIndex,
): ParticipantRoomChoiceLoader {
return new ParticipantRoomChoiceLoader(
$this->choiceListFactory,
$adapter,
$selectedRooms,
$participantIndex
);
}
}