diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index da207cc..76bb7b0 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -162,6 +162,8 @@ class BookingDataProcessor $roomSelection->label = $room->label; $roomSelection->price = $room->price; $roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) + $roomSelection->capacity = $room->minPax ?? 1; + $roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0); $dto->roomSelections[] = $roomSelection; } } diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index 56991c9..85da2dd 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -172,7 +172,7 @@ class BookingPayloadBuilder '@idverpflegung' => $room->boardId, '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, '@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null, - '@anzahl' => $room->totalCount, + '@anzahl' => count($uniqueMapping), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)), ]; } @@ -456,21 +456,12 @@ class BookingPayloadBuilder $availableRooms = $bookingDto->travel->getAvailableRooms(); $payload['ferienzielunterbringungen']['ferienzielunterbringung'] = []; - // Build room selection quantity lookup - $roomQuantities = []; - foreach ($bookingDto->roomSelections as $selection) { - if ($selection->quantity > 0) { - $roomQuantities[$selection->id] = $selection->quantity; - } - } - foreach ($roomMap as $roomId => $participantIds) { $room = $availableRooms[$roomId] ?? null; if (null === $room) { continue; } - $quantity = $roomQuantities[$roomId] ?? 1; $uniqueParticipantIds = array_unique($participantIds); $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ @@ -479,7 +470,7 @@ class BookingPayloadBuilder '@idverpflegung' => $room->boardId, '@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'), '@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'), - '@anzahl' => $quantity, + '@anzahl' => count($uniqueParticipantIds), '@zuordnung' => implode(',', $uniqueParticipantIds), ]; } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 96bcc32..30b6afc 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -132,6 +132,11 @@ class ParticipantEditDto #[Assert\Callback(groups: ['booking_create', 'booking_edit'])] public function validateEmailUniqueness(ExecutionContextInterface $context): void { + // Skip for internal agency bookings — staff use shared placeholder emails + if ($this->bookingContext->isInternalAgencyBooking()) { + return; + } + // Skip validation for applicant (index 0) - applicant's email can be shared with dependents if (true === $this->participant->isApplicant()) { return; diff --git a/src/Form/Service/Condition/AccommodationMutabilityCondition.php b/src/Form/Service/Condition/AccommodationMutabilityCondition.php new file mode 100644 index 0000000..e5bb0fc --- /dev/null +++ b/src/Form/Service/Condition/AccommodationMutabilityCondition.php @@ -0,0 +1,29 @@ +travel->roomsMutable; + } + + public function getDependentFields(): array + { + return []; + } + + public function getDescription(): string + { + return 'Accommodation is not mutable (edit flow)'; + } +} diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 6a94907..a2502a0 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -5,11 +5,10 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; -use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractFieldStateProvider; +use App\Form\Service\Condition\AccommodationMutabilityCondition; use App\Form\Service\Condition\AdditionalServicesMutabilityCondition; use App\Form\Service\Condition\AgeRangeCondition; -use App\Form\Service\Condition\BookingModeCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; @@ -99,10 +98,10 @@ class EditFieldStateProvider extends AbstractFieldStateProvider 'static_text' => $personalDataHiddenCondition, ]; - // Room assignments are fixed in edit mode - always render as static text - // The assignedRoomId value passes through unchanged from the loaded booking data + // Room assignments are readonly when BPN indicates accommodation is not mutable + $accommodationMutabilityCondition = new AccommodationMutabilityCondition(); $this->fieldStateConditions['assignedRoomId'] = [ - 'static_text' => new BookingModeCondition(BookingDto::MODE_EDIT), + 'static_text' => $accommodationMutabilityCondition, ]; // Show remarks room field only when room with code 'mbz' (single bed) is selected diff --git a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php index 262b6be..6698531 100644 --- a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -122,7 +122,14 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle } // Calculate total capacity for this room type - $totalCapacity = $roomSelection->quantity * $roomSelection->capacity; + // In edit mode, use maxQuantity which includes available rooms beyond the booked count. + // In create mode, use quantity which is the user's room selection from step 1. + if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { + $effectiveQuantity = $roomSelection->maxQuantity; + } else { + $effectiveQuantity = $roomSelection->quantity ?? 0; + } + $totalCapacity = $effectiveQuantity * $roomSelection->capacity; // Count current occupancy (excluding current participant) $occupancy = 0; @@ -174,7 +181,12 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle return; // Shouldn't happen } - $totalCapacity = $roomSelection->quantity * $roomSelection->capacity; + if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { + $effectiveQuantity = $roomSelection->maxQuantity; + } else { + $effectiveQuantity = $roomSelection->quantity ?? 0; + } + $totalCapacity = $effectiveQuantity * $roomSelection->capacity; $currentOccupancy = count($participantsWithRoom); $spacesNeeded = ($currentOccupancy + 1) - $totalCapacity; diff --git a/src/Service/ParticipantCardDataService.php b/src/Service/ParticipantCardDataService.php index fc7edf6..904355c 100644 --- a/src/Service/ParticipantCardDataService.php +++ b/src/Service/ParticipantCardDataService.php @@ -237,8 +237,8 @@ class ParticipantCardDataService } else { $groups[] = 'booking_edit'; - // In edit mode, add strict_required only if applicant is immutable - if (false === ($bookingDto->participants[0]?->mutable ?? true)) { + // Strict validation in edit mode except for internal agency bookings + if (false === $bookingDto->isInternalAgencyBooking()) { $groups[] = 'strict_required'; } } diff --git a/src/Service/RoomPricingCalculator.php b/src/Service/RoomPricingCalculator.php index 81cc799..4f2ea2c 100644 --- a/src/Service/RoomPricingCalculator.php +++ b/src/Service/RoomPricingCalculator.php @@ -135,6 +135,10 @@ class RoomPricingCalculator } } + // Count current participant assignments from the DTO (reflects live state + // including new assignments not yet in the booking entity) + $assignmentCounts = $bookingDto->getRoomAssignmentCounts(); + // Build pricing array foreach ($roomGroups as $roomId => $data) { $room = $data['room']; @@ -144,10 +148,14 @@ class RoomPricingCalculator // Calculate average unit price (price per person) $unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; + // Use live assignment count for quantity display so the sidebar reflects + // new room assignments that haven't been persisted to the booking entity yet + $quantity = $assignmentCounts[$roomId] ?? $room->totalCount; + $roomPricing[] = [ 'roomId' => $room->id, 'label' => $room->label, - 'quantity' => $room->totalCount, + 'quantity' => $quantity, 'participantCount' => $participantCount, 'unitPrice' => $unitPrice, 'totalPrice' => $totalPrice, diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index 3347b0c..54ecadd 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -104,6 +104,18 @@ {% endif %} + + + Unterkunft + + + {% if mutableData.items['accommodation'].mutable %} + {{ mutableData.items['accommodation'].mutableBefore|date('d.m.Y') }} + {% else %} + nicht mehr möglich + {% endif %} + + {% endif %} diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index 69458de..f71f0da 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Form\Model; use App\BusProNet\Model\Travel; +use App\BusProNet\XmlLoader\AgencyLoader; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantEditDto; @@ -373,6 +374,46 @@ class ParticipantEditDtoTest extends TestCase $this->assertCount(0, $violations5); } + public function testInternalAgencyBookingSkipsEmailUniqueness(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('shared@ep-reisen.de'), + $this->createAdultParticipant('shared@ep-reisen.de'), + $this->createAdultParticipant('shared@ep-reisen.de'), + ]); + + // Mark as internal agency booking + $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; + + // Validate second participant (non-applicant adult with duplicate email) + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[1], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(0, $violations, 'Internal agency bookings should skip email uniqueness validation'); + } + + public function testNonAgencyBookingStillEnforcesEmailUniqueness(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('shared@example.com'), + $this->createAdultParticipant('shared@example.com'), + ]); + + // Non-agency booking (default agencyCode is null) + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[1], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(1, $violations, 'Non-agency bookings should still enforce email uniqueness'); + } + // ========================================= // Ski Pass Validation Tests (Baby Age Exemption) // ========================================= diff --git a/tests/Form/Service/Condition/AccommodationMutabilityConditionTest.php b/tests/Form/Service/Condition/AccommodationMutabilityConditionTest.php new file mode 100644 index 0000000..864a8a9 --- /dev/null +++ b/tests/Form/Service/Condition/AccommodationMutabilityConditionTest.php @@ -0,0 +1,71 @@ +condition = new AccommodationMutabilityCondition(); + } + + public function testReturnsFalseWhenRoomsMutable(): void + { + $travel = new Travel(); + $travel->roomsMutable = true; + + $bookingDto = new BookingDto($travel, 1); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Should return false when rooms are mutable (field should be editable)'); + } + + public function testReturnsTrueWhenRoomsNotMutable(): void + { + $travel = new Travel(); + $travel->roomsMutable = false; + + $bookingDto = new BookingDto($travel, 1); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertTrue($result, 'Should return true when rooms are not mutable (field should be static text)'); + } + + public function testDefaultTravelHasMutableRooms(): void + { + $travel = new Travel(); + + $bookingDto = new BookingDto($travel, 1); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Default travel should have mutable rooms'); + } + + public function testGetDependentFieldsReturnsEmptyArray(): void + { + $result = $this->condition->getDependentFields(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetDescriptionReturnsString(): void + { + $result = $this->condition->getDescription(); + + $this->assertIsString($result); + $this->assertStringContainsString('not mutable', $result); + } +}