diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 83e02fd..a176c64 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,8 +1,11 @@ { "permissions": { "allow": [ - "Bash(php -l:*)" + "Bash(php -l:*)", + "Bash(./vendor/bin/phpunit --testdox)", + "Bash(./vendor/bin/php-cs-fixer fix:*)", + "Bash(./vendor/bin/php-cs-fixer fix:*)" ], "deny": [] } -} +} \ No newline at end of file diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 643b238..1401e02 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -358,7 +358,7 @@ class BookingDataProcessor $payload['zusatzleistungen']['zusatzleistung'][] = [ '@idleistung' => $service->id, '@anzahl' => count($service->mapping), - '@zuordnung' => implode(',', array_map(fn($index) => $index + 1, $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(',', array_map(fn($index) => $index + 1, $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(',', array_map(fn($index) => $index + 1, $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(',', array_map(fn($index) => $index + 1, $pickup->mapping)), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)), ]; } } diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index d80716d..0c04216 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -187,7 +187,7 @@ 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]; } @@ -195,7 +195,7 @@ class Booking // 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]; } diff --git a/src/BusProNet/Model/Room.php b/src/BusProNet/Model/Room.php index 219f1c3..73052b1 100644 --- a/src/BusProNet/Model/Room.php +++ b/src/BusProNet/Model/Room.php @@ -86,6 +86,7 @@ class Room if (1 === preg_match('/bett/i', $this->label)) { return self::SELECTION_TYPE_BY_PAX; } + return self::SELECTION_TYPE_BY_ROOM; } } diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index ac2816c..5d6a8c3 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -87,23 +87,46 @@ class Travel public ?Guide $guide = null; /** - * Retrieves additional services filtered by group and availability. + * Retrieves additional services filtered by subtype, availability, and optionally by travel date range. * - * Filters additional services based on the provided group(s) and optionally - * by availability. Services are sorted alphabetically by label. + * Filters additional services based on the provided subtype(s), availability, + * and optionally whether their date range overlaps with the travel dates. + * Services with null dates are considered always available when date filtering is enabled. + * Services are sorted alphabetically by label. * - * @param mixed $group The service group(s) to filter by - * @param bool $availableOnly Whether to include only available services + * @param mixed $subTypes The service subtype(s) to filter by + * @param bool $filterAvailable Whether to include only available services + * @param bool $filterByTravelDateRange Whether to filter by travel date range overlap * * @return array The filtered and sorted services array */ - public function getAdditionalServicesByGroup(mixed $group, bool $availableOnly = true): array + public function getAdditionalServicesBySubTypes(mixed $subTypes, bool $filterAvailable = true, bool $filterByTravelDateRange = false): array { - $group = (array) $group; + $subTypes = (array) $subTypes; - $services = array_filter($this->additionalServices, function (Service $service) use ($group, $availableOnly) { - return true === in_array($service->subType, $group) - && (false === $availableOnly || 0 < $service->available || null === $service->available); + $services = array_filter($this->additionalServices, function (Service $service) use ($subTypes, $filterAvailable, $filterByTravelDateRange) { + // Check subtype + if (false === in_array($service->subType, $subTypes)) { + return false; + } + + // Check availability + if (true === $filterAvailable && null !== $service->available && 0 >= $service->available) { + return false; + } + + // Check date range overlap if filtering by travel dates is enabled + if (true === $filterByTravelDateRange) { + if (null !== $service->dateFrom && null !== $service->dateTo + && null !== $this->dateFrom && null !== $this->dateTo) { + // Service is available if its date range overlaps with travel dates: + // service.dateFrom <= travel.dateTo AND service.dateTo >= travel.dateFrom + return $service->dateFrom <= $this->dateTo && $service->dateTo >= $this->dateFrom; + } + // Service is available if it has no date constraints or travel has no dates + } + + return true; }); usort($services, function (Service $a, Service $b) { @@ -119,17 +142,17 @@ class Travel * Filters transportation services based on travel direction and optionally * by availability. Services are sorted by subtype. * - * @param string $direction The travel direction to filter by - * @param bool $availableOnly Whether to include only available services + * @param string $direction The travel direction to filter by + * @param bool $filterAvailable Whether to include only available services * * @return array The filtered and sorted transportation services */ - public function getTransportationServicesByDirection(string $direction, bool $availableOnly = true): array + public function getTransportationServicesByDirection(string $direction, bool $filterAvailable = true): array { - $services = array_filter($this->transportationServices, function (Service $service) use ($direction, $availableOnly) { + $services = array_filter($this->transportationServices, function (Service $service) use ($direction, $filterAvailable) { return $direction === $service->direction && $service->price >= 0 - && (false === $availableOnly || $service->available > 0 || null === $service->available); + && (false === $filterAvailable || $service->available > 0 || null === $service->available); }); usort($services, function (Service $a, Service $b) { diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 7581f58..5b39deb 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -163,7 +163,7 @@ class BookingCreateParticipantType extends AbstractType */ private function addDynamicFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void { - $dynamicFields = ['assignedRoomId', 'courses']; // List of fields that need dynamic configuration + $dynamicFields = ['assignedRoomId', 'courses', 'additionalServices', 'board', 'rentals']; // List of fields that need dynamic configuration foreach ($dynamicFields as $fieldName) { if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index 158127b..5a1eb2d 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -70,7 +70,7 @@ class BookingEditType extends AbstractType { // combine selectable services from travel data with additional services // from booking data - $selectableServices = $data->travel->getAdditionalServicesByGroup($subType); + $selectableServices = $data->travel->getAdditionalServicesBySubTypes($subType); $selectableServiceIds = array_map(function (Service $service) { return $service->id; }, $selectableServices); diff --git a/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php b/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php index d3b3aa2..4119494 100644 --- a/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php +++ b/src/Form/ChoiceLoader/ParticipantRoomChoiceLoader.php @@ -83,8 +83,8 @@ class ParticipantRoomChoiceLoader implements ChoiceLoaderInterface --$adjustedOccupancy; } - $totalAvailableRooms = $roomSelection->quantity; - $remainingCapacity = $totalAvailableRooms - $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) { diff --git a/src/Form/Model/RoomSelectionDto.php b/src/Form/Model/RoomSelectionDto.php index ccd5af2..c3c10e9 100644 --- a/src/Form/Model/RoomSelectionDto.php +++ b/src/Form/Model/RoomSelectionDto.php @@ -15,5 +15,5 @@ class RoomSelectionDto public int $maxQuantity = 100; - public int $minPax = 0; + public int $capacity = 0; } diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 46a0305..2182f60 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -141,4 +141,4 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * specific field state conditions. */ abstract protected function registerFieldStateConditions(): void; -} \ No newline at end of file +} diff --git a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index b9cd677..312b098 100644 --- a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -63,6 +63,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle protected function getParticipant(BookingDtoInterface $bookingDto, int $participantIndex): ?object { $participants = $bookingDto->getParticipants(); + return $participants[$participantIndex] ?? null; } @@ -151,4 +152,4 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle { return []; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/AgeRangeCondition.php b/src/Form/Service/Condition/AgeRangeCondition.php index 2b3ef48..d1d2fe1 100644 --- a/src/Form/Service/Condition/AgeRangeCondition.php +++ b/src/Form/Service/Condition/AgeRangeCondition.php @@ -138,4 +138,4 @@ class AgeRangeCondition implements FieldConditionInterface return (int) $dateOfBirth->diff($today)->y; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/ApplicantCondition.php b/src/Form/Service/Condition/ApplicantCondition.php index 237420b..9656a0a 100644 --- a/src/Form/Service/Condition/ApplicantCondition.php +++ b/src/Form/Service/Condition/ApplicantCondition.php @@ -30,7 +30,7 @@ class ApplicantCondition implements FieldConditionInterface public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool { // Default: applicant is the first participant (index 0) - return $participantIndex === 0; + return 0 === $participantIndex; } public function getDependentFields(): array @@ -42,4 +42,4 @@ class ApplicantCondition implements FieldConditionInterface { return 'Checks if the participant is the applicant (index 0)'; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/CompositeCondition.php b/src/Form/Service/Condition/CompositeCondition.php index 9ddac2e..6ae5f42 100644 --- a/src/Form/Service/Condition/CompositeCondition.php +++ b/src/Form/Service/Condition/CompositeCondition.php @@ -231,4 +231,4 @@ class CompositeCondition implements FieldConditionInterface throw new \InvalidArgumentException(sprintf('%s operator requires at least one condition', $operator)); } } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/FieldValueCondition.php b/src/Form/Service/Condition/FieldValueCondition.php index 2bc385a..570c36e 100644 --- a/src/Form/Service/Condition/FieldValueCondition.php +++ b/src/Form/Service/Condition/FieldValueCondition.php @@ -263,4 +263,4 @@ class FieldValueCondition implements FieldConditionInterface throw new \InvalidArgumentException(sprintf('Operator "%s" does not accept expectedValue parameter', $operator)); } } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/MutabilityCondition.php b/src/Form/Service/Condition/MutabilityCondition.php index b59cce4..8bfb05e 100644 --- a/src/Form/Service/Condition/MutabilityCondition.php +++ b/src/Form/Service/Condition/MutabilityCondition.php @@ -46,4 +46,4 @@ class MutabilityCondition implements FieldConditionInterface { return 'Checks if the participant\'s personal data is mutable (edit flow)'; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Contract/FieldConditionInterface.php b/src/Form/Service/Contract/FieldConditionInterface.php index 4cf8192..5e4f4c2 100644 --- a/src/Form/Service/Contract/FieldConditionInterface.php +++ b/src/Form/Service/Contract/FieldConditionInterface.php @@ -59,4 +59,4 @@ interface FieldConditionInterface * @return string A brief description of the condition logic */ public function getDescription(): string; -} \ No newline at end of file +} diff --git a/src/Form/Service/Contract/FieldOptionsProviderInterface.php b/src/Form/Service/Contract/FieldOptionsProviderInterface.php index a1d72c9..ff38203 100644 --- a/src/Form/Service/Contract/FieldOptionsProviderInterface.php +++ b/src/Form/Service/Contract/FieldOptionsProviderInterface.php @@ -61,4 +61,4 @@ interface FieldOptionsProviderInterface * @return bool True if the field has registered option providers, false otherwise */ public function hasFieldOptions(string $fieldName): bool; -} \ No newline at end of file +} diff --git a/src/Form/Service/Contract/FieldStateProviderInterface.php b/src/Form/Service/Contract/FieldStateProviderInterface.php index 544b36b..b476e37 100644 --- a/src/Form/Service/Contract/FieldStateProviderInterface.php +++ b/src/Form/Service/Contract/FieldStateProviderInterface.php @@ -98,4 +98,4 @@ interface FieldStateProviderInterface * @return array> Field states indexed by field name */ public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array; -} \ No newline at end of file +} diff --git a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php index 02cbbd9..8fdc81b 100644 --- a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php +++ b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php @@ -67,4 +67,4 @@ interface ParticipantFieldHandlerInterface * @return string[] Array of field names that this handler may modify the state of */ public function getAffectedFieldNames(): array; -} \ No newline at end of file +} diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 59d4cae..1c41ce0 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace App\Form\Service; +use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\ApplicantCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\MutabilityCondition; -use App\Form\Service\Abstract\AbstractFieldStateProvider; /** * Field state provider for the booking edit workflow. diff --git a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php index 5d95274..3f68b4b 100644 --- a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -71,4 +71,4 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle // Convert form string to integer, handling empty selections as null $participant->assignedRoomId = $this->normalizeIntValue($roomId); } -} \ No newline at end of file +} diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index df32253..9391cb0 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -201,4 +201,4 @@ class ParticipantFieldHandlerRegistry return $result; } -} \ No newline at end of file +} diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index a2453b8..df9febd 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -92,15 +92,56 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => true, 'expanded' => true, 'required' => false, - 'choices' => array_reduce( - $bookingDto->travel->getAdditionalServicesByGroup(Constants::TOKEN_COURSES), - function (array $choices, Service $service) { - $choices[$service->label] = $service->id; + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), + 'choice_label' => 'label', + ]; - return $choices; - }, - [] - ), + // Additional services field provider - provides additional services with mandatory pre-selection + $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto) => [ + 'label' => 'Zusatzleistungen', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), + 'choice_value' => 'id', + 'choice_label' => 'label', + 'choice_attr' => function (?Service $service) { + if (null === $service) { + return []; + } + + $attributes = []; + + // Pre-select and make readonly for mandatory services + if (true === $service->mandatory) { + $attributes['checked'] = true; + $attributes['readonly'] = true; + $attributes['class'] = 'text-pink'; + $attributes['title'] = 'Diese Leistung ist nicht abwählbar'; + } + + return $attributes; + }, + ]; + + // Board field provider - provides available board options from travel data + $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto) => [ + 'label' => 'Verpflegung', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), + 'choice_label' => 'label', + ]; + + // Rentals field provider - provides available rental options from travel data filtered by date range + $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto) => [ + 'label' => 'Leihmaterial', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true), + 'choice_label' => 'label', ]; // Future field providers would be added here, for example: diff --git a/src/Form/Service/Trait/FormTraversalTrait.php b/src/Form/Service/Trait/FormTraversalTrait.php index 3dd57ff..327f2e9 100644 --- a/src/Form/Service/Trait/FormTraversalTrait.php +++ b/src/Form/Service/Trait/FormTraversalTrait.php @@ -39,4 +39,4 @@ trait FormTraversalTrait return $data instanceof BookingDtoInterface ? $data : null; } -} \ No newline at end of file +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 2d004e2..282fb19 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -101,7 +101,7 @@ class BookingService $selection->roomId = $room->id; $selection->roomLabel = $room->label; $selection->maxQuantity = $room->available; - $selection->minPax = $room->minPax; + $selection->capacity = $room->minPax; $selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0; return $selection; diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 054d56f..e80abbc 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -53,6 +53,15 @@ {% if participant.courses is defined %} {{ form_row(participant.courses) }} {% endif %} + {% if participant.additionalServices is defined %} + {{ form_row(participant.additionalServices) }} + {% endif %} + {% if participant.rentals is defined %} + {{ form_row(participant.rentals) }} + {% endif %} + {% if participant.board is defined %} + {{ form_row(participant.board) }} + {% endif %} diff --git a/tests/Service/TravelDataServiceTest.php b/tests/Service/TravelDataServiceTest.php index 087edf7..7ad7fe8 100644 --- a/tests/Service/TravelDataServiceTest.php +++ b/tests/Service/TravelDataServiceTest.php @@ -153,9 +153,9 @@ class TravelDataServiceTest extends TestCase $dateId => [ 'id' => $dateId, 'hotels' => [ - $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'] - ] - ] + $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'], + ], + ], ]; $this->travelLoader @@ -191,8 +191,8 @@ class TravelDataServiceTest extends TestCase $mapping = [ $dateId => [ 'id' => $dateId, - 'hotels' => [] - ] + 'hotels' => [], + ], ]; $this->travelLoader @@ -214,9 +214,9 @@ class TravelDataServiceTest extends TestCase $dateId => [ 'id' => $dateId, 'hotels' => [ - $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'] - ] - ] + $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'], + ], + ], ]; $this->travelLoader @@ -256,7 +256,7 @@ class TravelDataServiceTest extends TestCase ->expects($this->once()) ->method('get') ->with('travel_unified_12345_67890_local') - ->willReturnCallback(function (string $key, callable $callback) use ($cacheItem, $travel) { + ->willReturnCallback(function (string $key, callable $callback) use ($cacheItem) { return $callback($cacheItem); }); @@ -313,4 +313,4 @@ class TravelDataServiceTest extends TestCase $this->assertSame($travel, $result); } -} \ No newline at end of file +}