fix: allow updating room assignments in edit mode

This commit is contained in:
Björn Fromme
2026-03-16 12:02:59 +01:00
parent 380b1ed329
commit f46cc59041
11 changed files with 191 additions and 21 deletions
@@ -162,6 +162,8 @@ class BookingDataProcessor
$roomSelection->label = $room->label; $roomSelection->label = $room->label;
$roomSelection->price = $room->price; $roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $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; $dto->roomSelections[] = $roomSelection;
} }
} }
@@ -172,7 +172,7 @@ class BookingPayloadBuilder
'@idverpflegung' => $room->boardId, '@idverpflegung' => $room->boardId,
'@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null,
'@abreise' => $room->dateTo ? $room->dateTo->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)), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
]; ];
} }
@@ -456,21 +456,12 @@ class BookingPayloadBuilder
$availableRooms = $bookingDto->travel->getAvailableRooms(); $availableRooms = $bookingDto->travel->getAvailableRooms();
$payload['ferienzielunterbringungen']['ferienzielunterbringung'] = []; $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) { foreach ($roomMap as $roomId => $participantIds) {
$room = $availableRooms[$roomId] ?? null; $room = $availableRooms[$roomId] ?? null;
if (null === $room) { if (null === $room) {
continue; continue;
} }
$quantity = $roomQuantities[$roomId] ?? 1;
$uniqueParticipantIds = array_unique($participantIds); $uniqueParticipantIds = array_unique($participantIds);
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
@@ -479,7 +470,7 @@ class BookingPayloadBuilder
'@idverpflegung' => $room->boardId, '@idverpflegung' => $room->boardId,
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'), '@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'), '@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
'@anzahl' => $quantity, '@anzahl' => count($uniqueParticipantIds),
'@zuordnung' => implode(',', $uniqueParticipantIds), '@zuordnung' => implode(',', $uniqueParticipantIds),
]; ];
} }
+5
View File
@@ -132,6 +132,11 @@ class ParticipantEditDto
#[Assert\Callback(groups: ['booking_create', 'booking_edit'])] #[Assert\Callback(groups: ['booking_create', 'booking_edit'])]
public function validateEmailUniqueness(ExecutionContextInterface $context): void 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 // Skip validation for applicant (index 0) - applicant's email can be shared with dependents
if (true === $this->participant->isApplicant()) { if (true === $this->participant->isApplicant()) {
return; return;
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that checks if accommodation (room assignments) are mutable in the edit flow.
*/
class AccommodationMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->roomsMutable;
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Accommodation is not mutable (edit flow)';
}
}
+4 -5
View File
@@ -5,11 +5,10 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\AccommodationMutabilityCondition;
use App\Form\Service\Condition\AdditionalServicesMutabilityCondition; use App\Form\Service\Condition\AdditionalServicesMutabilityCondition;
use App\Form\Service\Condition\AgeRangeCondition; use App\Form\Service\Condition\AgeRangeCondition;
use App\Form\Service\Condition\BookingModeCondition;
use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FieldValueCondition;
@@ -99,10 +98,10 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
'static_text' => $personalDataHiddenCondition, 'static_text' => $personalDataHiddenCondition,
]; ];
// Room assignments are fixed in edit mode - always render as static text // Room assignments are readonly when BPN indicates accommodation is not mutable
// The assignedRoomId value passes through unchanged from the loaded booking data $accommodationMutabilityCondition = new AccommodationMutabilityCondition();
$this->fieldStateConditions['assignedRoomId'] = [ $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 // Show remarks room field only when room with code 'mbz' (single bed) is selected
@@ -122,7 +122,14 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
} }
// Calculate total capacity for this room type // 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) // Count current occupancy (excluding current participant)
$occupancy = 0; $occupancy = 0;
@@ -174,7 +181,12 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
return; // Shouldn't happen 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); $currentOccupancy = count($participantsWithRoom);
$spacesNeeded = ($currentOccupancy + 1) - $totalCapacity; $spacesNeeded = ($currentOccupancy + 1) - $totalCapacity;
+2 -2
View File
@@ -237,8 +237,8 @@ class ParticipantCardDataService
} else { } else {
$groups[] = 'booking_edit'; $groups[] = 'booking_edit';
// In edit mode, add strict_required only if applicant is immutable // Strict validation in edit mode except for internal agency bookings
if (false === ($bookingDto->participants[0]?->mutable ?? true)) { if (false === $bookingDto->isInternalAgencyBooking()) {
$groups[] = 'strict_required'; $groups[] = 'strict_required';
} }
} }
+9 -1
View File
@@ -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 // Build pricing array
foreach ($roomGroups as $roomId => $data) { foreach ($roomGroups as $roomId => $data) {
$room = $data['room']; $room = $data['room'];
@@ -144,10 +148,14 @@ class RoomPricingCalculator
// Calculate average unit price (price per person) // Calculate average unit price (price per person)
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; $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[] = [ $roomPricing[] = [
'roomId' => $room->id, 'roomId' => $room->id,
'label' => $room->label, 'label' => $room->label,
'quantity' => $room->totalCount, 'quantity' => $quantity,
'participantCount' => $participantCount, 'participantCount' => $participantCount,
'unitPrice' => $unitPrice, 'unitPrice' => $unitPrice,
'totalPrice' => $totalPrice, 'totalPrice' => $totalPrice,
+12
View File
@@ -104,6 +104,18 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td class="p-2 align-top">
Unterkunft
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['accommodation'].mutable %}
{{ mutableData.items['accommodation'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
{% endif %} {% endif %}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Form\Model; namespace App\Tests\Form\Model;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto; use App\Form\Model\ParticipantEditDto;
@@ -373,6 +374,46 @@ class ParticipantEditDtoTest extends TestCase
$this->assertCount(0, $violations5); $this->assertCount(0, $violations5);
} }
public function testInternalAgencyBookingSkipsEmailUniqueness(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
// 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('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
// 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) // Ski Pass Validation Tests (Baby Age Exemption)
// ========================================= // =========================================
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service\Condition;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Service\Condition\AccommodationMutabilityCondition;
use PHPUnit\Framework\TestCase;
class AccommodationMutabilityConditionTest extends TestCase
{
private AccommodationMutabilityCondition $condition;
protected function setUp(): void
{
$this->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);
}
}