wip: modernized edit flow

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 0d073a36a3
commit 28831a3b06
75 changed files with 1662 additions and 747 deletions
+323
View File
@@ -0,0 +1,323 @@
# Booking DTO Unification Plan
## Problem Statement
The current implementation uses two separate DTOs (`BookingCreateDto` and `BookingEditDto`) with fundamentally different data structures:
- **Create mode**: Services stored in participant DTOs (e.g., `$participant->insurance`, `$participant->courses`)
- **Edit mode**: Services stored in the booking object (e.g., `$booking->insurances`, `$booking->additionalServices`)
This divergence causes multiple issues:
1. Pricing and summary calculations fail in edit mode
2. Field handlers need complex mode-specific logic
3. Data processor needs separate handling for create vs edit
4. Code duplication and increased complexity
5. Bugs due to assumptions about data structure
## Solution: Unified BookingDto
Create a single `BookingDto` class that stores all service selections in participant DTOs for BOTH create and edit modes. Different modes are handled through different instantiation methods.
## Implementation Plan
### Phase 1: Create Unified BookingDto Class
**File**: `src/Form/Model/BookingDto.php`
**Changes**:
- Merge `BookingCreateDto` and `BookingEditDto` into single `BookingDto` class
- Keep all participant-based service storage (insurance, courses, skiPass, rentals, transportation, etc.)
- Add `mode` property (MODE_CREATE or MODE_EDIT)
- Add `booking` property (null in create mode, Booking object in edit mode for metadata only)
- Implement `BookingDtoInterface` interface
**Constructor signatures**:
```php
// Create mode
public function __construct(Travel $travel, int $agencyId)
// Edit mode (static factory)
public static function fromBooking(Booking $booking, Travel $travel): static
```
**Key method**:
```php
public function getMode(): string
{
return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE;
}
```
### Phase 2: Update BookingDto::fromBooking() for Edit Mode
**File**: `src/Form/Model/BookingDto.php`
**Responsibilities**:
1. Extract services from booking object and assign to participant DTOs
2. Map insurances: `$participant->insurance = $booking->getInsuranceForParticipant($index)`
3. Map services: `$participant->courses = $booking->getAdditionalServicesForParticipantByGroup($index, 'COURSES')`
4. Map transportation: `$participant->transportationOutbound = $booking->getTransportationForParticipant($index, 'OUTBOUND')`
5. Map pickup locations
6. Map room assignments
7. Set body dimensions from applicant for participant 0
8. Set parking from form data (need to check if stored in booking)
**Data extraction methods needed**:
- `Booking::getInsuranceForParticipant(int $index): ?Insurance`
- Existing methods for other services can be reused
### Phase 3: Update ParticipantDto
**File**: `src/Form/Model/ParticipantDto.php`
**Changes**:
- Already has all necessary service properties
- Ensure `fromPersonalData()` copies body dimensions correctly
- No structural changes needed
### Phase 4: Update Form Types
**Files to update**:
- `src/Form/BookingType.php` → Rename to `BookingType` (generic)
- `src/Form/BookingEditType.php` → Delete (use unified BookingType)
- `src/Form/BookingParticipantType.php` → Already works with ParticipantDto, should work unchanged
**Changes**:
- Update `BookingType` to use `BookingDto::class` as data_class
- Remove mode-specific form type distinction
- Pass `edit_mode` option to child forms for field state provider selection
### Phase 5: Update Controllers
#### CreateStep2Controller
**File**: `src/Controller/Booking/CreateStep2Controller.php`
**Changes**:
- Change `BookingCreateDto``BookingDto`
- Constructor instantiation remains same
- All logic should work unchanged (services already in participant DTOs)
#### EditController
**File**: `src/Controller/Booking/EditController.php`
**Changes**:
- Change `BookingEditDto``BookingDto`
- Change `BookingEditDto::fromBooking()``BookingDto::fromBooking()`
- Form type: change `BookingEditType``BookingType` with `['edit_mode' => true]` option
- All pricing and summary calculations should now work (same DTO structure as create)
### Phase 6: Update BookingDataProcessor
**File**: `src/BusProNet/DataProcessor/BookingDataProcessor.php`
**Major simplification**:
#### createBookingRequestPayload() (Create flow)
- Already works with participant DTOs
- Change signature: `BookingDto` instead of `BookingCreateDto`
- No other changes needed
#### createUpdateRequestPayload() (Edit flow)
**Current problems**:
- Tries to read services from `$bookingDto->booking` object
- Complex mapping and resetting logic
- `processParticipantServices()` needs to add services to booking data from travel data
**New approach**:
- Services already in participant DTOs (populated by `fromBooking()`)
- Can reuse create flow logic almost entirely
- Only difference: include `idbuchung` and participant `idadresseperson` in payload
**Unified approach**:
```php
public function createPayload(BookingDto $bookingDto, string $type): array
{
// Apply bulk insurance if enabled
$this->applyBulkInsuranceIfActive($bookingDto);
// Collect service mappings (works same for both modes)
$serviceMap = $this->collectServiceMappings($bookingDto);
$transportationMap = $this->collectTransportationMappings($bookingDto);
$roomMap = $this->collectRoomMappings($bookingDto);
$pickupMap = $this->collectPickupMappings($bookingDto);
$insuranceMap = $this->collectInsuranceMappings($bookingDto);
// Build payload based on mode
if (BookingDtoInterface::MODE_EDIT === $bookingDto->getMode()) {
return $this->buildUpdatePayload($bookingDto, ...maps);
} else {
return $this->buildCreatePayload($bookingDto, $type, ...maps);
}
}
```
**Simplifications**:
- Remove `processParticipantServices()` complexity
- Remove `resetServiceMappings()`
- Remove `removeUnusedServices()`
- Direct mapping from participant DTOs to payload
### Phase 7: Update Field Handlers
**Files**: All handlers in `src/Form/Service/`
**Changes needed**:
- Handlers already work with participant DTOs
- No changes needed (they don't care about mode)
- Registry already handles both modes
### Phase 8: Update Service Layer
#### BookingService
**File**: `src/Service/BookingService.php`
**Changes**:
- Update type hints: `BookingCreateDto|BookingEditDto``BookingDto`
- `getRoomSummaryAndParticipantCount()` should now work for both modes (same DTO structure)
- No logic changes needed
#### PriceCalculator
**File**: `src/Service/PriceCalculator.php`
**Changes**:
- Update type hints to use `BookingDto`
- All calculations work with participant DTOs, should work unchanged
- Mode detection: `$bookingDto->getMode()` instead of `instanceof` checks
### Phase 9: Update Field State Providers
**Files**:
- `src/Form/Service/CreateFieldStateProvider.php`
- `src/Form/Service/EditFieldStateProvider.php`
**Changes**:
- Already use `BookingDtoInterface`, no changes needed
- Continue to be selected based on `edit_mode` form option
### Phase 10: Update Templates
**Files**:
- `templates/booking/create_step_2.html.twig`
- `templates/booking/edit.html.twig`
**Changes**:
- Variable naming: `bookingCreateDto``bookingDto`, `bookingEditDto``bookingDto`
- All logic should work unchanged (both render participant forms)
### Phase 11: Critical New Method in Booking Model
**File**: `src/BusProNet/Model/Booking.php`
**Add method**:
```php
public function getInsuranceForParticipant(int $index): ?Insurance
{
foreach ($this->insurances as $insurance) {
if (in_array($index, $insurance->mapping)) {
return $insurance;
}
}
return null;
}
```
Similar methods may be needed for other services if not already present.
## Migration Strategy
### Step 1: Create new BookingDto (keep old DTOs)
- Create `src/Form/Model/BookingDto.php`
- Implement both constructor and `fromBooking()`
- Keep `BookingCreateDto` and `BookingEditDto` temporarily
### Step 2: Update edit flow to use new DTO
- Update `EditController` to use `BookingDto`
- Update `BookingDataProcessor::createUpdateRequestPayload()` to accept `BookingDto`
- Test edit flow thoroughly
### Step 3: Update create flow to use new DTO
- Update `CreateStep2Controller` to use `BookingDto`
- Test create flow thoroughly
### Step 4: Cleanup
- Delete `BookingCreateDto.php`
- Delete `BookingEditDto.php`
- Delete `BookingEditType.php`
- Update all remaining type hints
## Testing Checklist
### Edit Flow
- [ ] Load existing booking with all service types
- [ ] Form displays all current selections correctly
- [ ] Summary sidebar shows all services and pricing
- [ ] Body dimensions show for applicant
- [ ] Change insurance (individual and bulk)
- [ ] Change services (courses, skiPass, rentals, board)
- [ ] Change transportation
- [ ] Submit changes successfully
- [ ] API receives correct payload with insurances
- [ ] After redirect, summary shows correctly
### Create Flow
- [ ] Select rooms
- [ ] Add participants
- [ ] Select services for participants
- [ ] Select insurances (individual and bulk)
- [ ] Pricing calculates correctly
- [ ] Summary shows all selections
- [ ] Submit creates booking successfully
### Field State System
- [ ] Conditional fields show/hide correctly in both modes
- [ ] Readonly fields work in edit mode
- [ ] Age-dependent fields work in both modes
- [ ] Bulk insurance checkbox works
### Data Integrity
- [ ] No service data loss during mode transitions
- [ ] Insurance IDs match correctly
- [ ] Participant indices correct in both modes
- [ ] Room assignments preserved
## Benefits of Unified DTO
1. **Single source of truth**: All service selections in one place (participant DTOs)
2. **Simplified calculations**: Pricing, summary, and totals work identically for both modes
3. **Reduced complexity**: No mode-specific logic in services and calculators
4. **Easier testing**: One DTO structure to test
5. **Better maintainability**: Changes to service structure only need updating in one place
6. **Consistent field handlers**: Handlers work with same data structure regardless of mode
7. **Cleaner templates**: Same rendering logic for both modes
## Risks and Mitigation
### Risk: Breaking existing create flow
**Mitigation**: Migrate edit flow first, test thoroughly, then migrate create flow
### Risk: Data loss during form processing
**Mitigation**: Extensive logging during migration, compare payloads before/after
### Risk: Field handler incompatibility
**Mitigation**: Field handlers already work with ParticipantDto, minimal changes needed
### Risk: Performance impact from data extraction
**Mitigation**: `fromBooking()` runs once per request, acceptable overhead
## Timeline Estimate
- **Phase 1-3** (DTO creation): 2 hours
- **Phase 4-5** (Forms & Controllers): 2 hours
- **Phase 6** (DataProcessor refactor): 3 hours
- **Phase 7-10** (Service layer & templates): 2 hours
- **Phase 11** (Booking model methods): 1 hour
- **Testing & Fixes**: 3 hours
**Total**: ~13 hours
## Notes
- Current code has accumulated technical debt from multiple iterations
- Clean refactor will improve long-term maintainability
- Most existing logic can be reused (field handlers, conditions, validators)
- Main work is in `fromBooking()` extraction logic and DataProcessor simplification
+6 -4
View File
@@ -16,6 +16,7 @@ use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingEditDto;
use App\Form\Model\RegistrationDto;
use League\Flysystem\FilesystemException;
@@ -48,6 +49,7 @@ class ApiClient
private readonly ApiResponseParser $responseParser,
private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger,
private readonly BookingDataProcessor $bookingDataProcessor,
array $options,
) {
$this->config = $this->resolveOptions($options);
@@ -192,9 +194,9 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function updateBooking(BookingEditDto $formData, bool $debug = false): Notification|BookingUpdate
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
{
$payload = (new BookingDataProcessor())->createUpdateRequestPayload($formData);
$payload = $this->bookingDataProcessor->createUpdateRequestPayload($formData);
$data = [
'user' => $this->config['bpn_username'],
@@ -222,7 +224,7 @@ class ApiClient
*/
public function createBookingInquiry(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage');
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Anfrage');
$data = [
'user' => $this->config['bpn_username'],
@@ -249,7 +251,7 @@ class ApiClient
*/
public function createBooking(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung');
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Buchung');
$data = [
'user' => $this->config['bpn_username'],
@@ -5,9 +5,18 @@ declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingEditDto;
use App\Form\Model\ParticipantDto;
use App\Service\InsuranceMatchingService;
/**
* Processes booking form data and converts it into BusProNet API payload format.
@@ -20,6 +29,89 @@ use App\Form\Model\BookingEditDto;
*/
class BookingDataProcessor
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
) {
}
/**
* Creates a BookingDto from an existing Booking entity (for edit mode).
*
* Extracts all service selections from the booking entity and assigns them
* to participant DTOs, creating a unified data structure identical to create mode.
*/
public function createBookingDtoFromBooking(Booking $booking, Travel $travel): BookingDto
{
$dto = new BookingDto($travel, $booking->hotelId);
$dto->booking = $booking;
$dto->agencyId = $booking->agencyId;
// Map payment ID from API to form payment method
$dto->paymentMethod = match ($booking->paymentId) {
(string) Constants::PAYMENT_TYPE_ID_DEBIT => Constants::PAYMENT_METHOD_DEBIT,
(string) Constants::PAYMENT_TYPE_ID_TRANSFER => Constants::PAYMENT_METHOD_TRANSFER,
default => Constants::PAYMENT_METHOD_TRANSFER,
};
if (null !== $booking->bankAccount) {
$dto->bankAccount = BankAccountDto::fromBankAccount($booking->bankAccount);
}
foreach ($booking->participants as $index => $participant) {
/** @var PersonalData $participant */
$participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index;
// Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
$participantData->skiPass = $booking->getSkiPassForParticipant($index);
$participantData->additionalServices = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL);
$participantData->board = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD);
$participantData->rentals = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS);
// Rental insurance (single service, not array)
$participantData->rentalInsurance = $booking->getRentalInsuranceForParticipant($index);
$participantData->rentalInsuranceSelected = null !== $participantData->rentalInsurance;
// Transportation services
$participantData->transportationOutbound = $booking
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING);
$participantData->transportationInbound = $booking
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING);
// Pickup location
$participantData->pickup = $booking->getPickupForParticipant($index);
// Parking (stored in form data, not in booking entity - needs special handling)
// For now, leave as false - may need to extract from transportation services
$participantData->parking = false;
// License plate (not stored in booking entity)
$participantData->licensePlate = null;
// Insurance
$participantData->insurance = $booking->getInsuranceForParticipant($index);
// Room assignment
$room = $booking->getRoomForParticipant($index);
$participantData->assignedRoomId = $room?->id;
$dto->participants[$index] = $participantData;
}
return $dto;
}
/**
* Creates an update request payload for the BusProNet API from booking form data.
*
@@ -34,18 +126,29 @@ class BookingDataProcessor
* - Adding new services from travel data when participants select them
* - Removing services with no participant mappings
* - Updating participant personal data from form input
* - Synchronizing applicant data with first participant details
* - Building the final API payload structure
*
* @param BookingEditDto|null $formData The booking edit form data containing updated participant and service selections
* IMPORTANT: The applicant's address must be preserved from the original booking data stored in the DTO,
* as it can be lost during form binding when ParticipantDto stores address references.
*
* @param BookingDto|null $formData The booking edit form data containing updated participant and service selections
*
* @return array The structured payload array ready for BusProNet API submission
*/
public function createUpdateRequestPayload(?BookingEditDto $formData): array
public function createUpdateRequestPayload(?BookingDto $formData): array
{
// Apply bulk insurance if enabled (modifies DTO in place)
$this->applyBulkInsuranceIfActive($formData);
$bookingData = $formData->booking;
$travelData = $formData->travel;
// CRITICAL: The applicant address may have been lost during form binding because ParticipantDto::fromPersonalData()
// stores a reference to the address object, and Symfony's form binding can modify it in place.
// Since we can't easily restore it here without the original booking, we rely on the EditController
// to preserve the original booking's applicant data by fetching fresh data before calling updateBooking().
// The applicant data should NEVER be modified in edit mode.
$this->resetServiceMappings($bookingData);
foreach ($formData->participants as $participant) {
@@ -54,11 +157,10 @@ class BookingDataProcessor
$this->removeUnusedServices($bookingData);
$this->updateParticipantPersonalData($formData->participants, $bookingData);
$this->syncApplicantData($bookingData);
$payload = $this->buildBasePayload($bookingData);
$this->addBankAccountToPayload($payload, $bookingData);
$this->buildParticipantPayload($payload, $bookingData);
$this->buildParticipantPayload($payload, $bookingData, $formData->participants);
$this->buildServicesPayload($payload, $bookingData);
$this->buildPickupPayload($payload, $bookingData);
@@ -71,9 +173,9 @@ class BookingDataProcessor
* This ensures that service assignments are rebuilt from scratch based on current form selections.
* Includes resetting room mappings to allow room reassignments during edit.
*
* @param object $bookingData The booking data object containing services to reset
* @param Booking $bookingData The booking data object containing services to reset
*/
private function resetServiceMappings(object $bookingData): void
private function resetServiceMappings(Booking $bookingData): void
{
$servicesToReset = [
...$bookingData->additionalServices,
@@ -81,6 +183,7 @@ class BookingDataProcessor
...$bookingData->pickupsOutbound,
...$bookingData->pickupsInbound,
...$bookingData->rooms,
...$bookingData->insurances,
];
foreach ($servicesToReset as $service) {
@@ -94,11 +197,11 @@ class BookingDataProcessor
* This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, pickup locations, and room assignments.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Travel $travelData The travel data containing available services
*/
private function processParticipantServices(object $participant, object $bookingData, object $travelData): void
private function processParticipantServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{
if (true === $participant->isCanceled()) {
return;
@@ -108,21 +211,22 @@ class BookingDataProcessor
$this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData);
}
/**
* Processes additional services for a participant.
* Collects additional services from a participant for mapping.
*
* Maps additional services (courses, ski passes, board options, rentals) to the participant.
* Adds new services to the booking if they don't already exist and sets individual pricing.
* Extracts all additional services (courses, board, rentals, ski pass, rental insurance,
* additional services) from a participant into a flat array of Service objects.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
* @param ParticipantDto $participant The participant data
*
* @return array<Service> Array of services selected by this participant
*/
private function processAdditionalServices(object $participant, object $bookingData, object $travelData): void
private function collectParticipantAdditionalServices(ParticipantDto $participant): array
{
$servicesToMap = [
$services = [
...$participant->courses,
...$participant->additionalServices,
...$participant->board,
@@ -131,9 +235,31 @@ class BookingDataProcessor
// Add ski pass if selected (single service, not an array)
if (null !== $participant->skiPass) {
$servicesToMap[] = $participant->skiPass;
$services[] = $participant->skiPass;
}
// Add rental insurance if selected (single service, not an array)
if (null !== $participant->rentalInsurance) {
$services[] = $participant->rentalInsurance;
}
return $services;
}
/**
* Processes additional services for a participant.
*
* Maps additional services (courses, ski passes, board options, rentals) to the participant.
* Adds new services to the booking if they don't already exist and sets individual pricing.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Travel $travelData The travel data containing available services
*/
private function processAdditionalServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{
$servicesToMap = $this->collectParticipantAdditionalServices($participant);
foreach ($servicesToMap as $service) {
if (false === isset($bookingData->additionalServices[$service->id])) {
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
@@ -152,11 +278,11 @@ class BookingDataProcessor
* Maps transportation services (both directions: to and from destination) to the participant.
* Adds new transportation services to the booking if they don't already exist.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Travel $travelData The travel data containing available services
*/
private function processTransportationServices(object $participant, object $bookingData, object $travelData): void
private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) {
if (false === isset($bookingData->transportationServices[$service->id])) {
@@ -176,10 +302,10 @@ class BookingDataProcessor
* Only processes pickup locations for bus transportation services and maps the participant
* to their selected pickup location.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
*/
private function processPickupLocations(object $participant, object $bookingData): void
private function processPickupLocations(ParticipantDto $participant, Booking $bookingData): void
{
// Check if either transportation direction is BUS and pickup is selected
$hasOutboundBus = null !== $participant->transportationOutbound && 'BUS' === $participant->transportationOutbound->subType;
@@ -200,10 +326,10 @@ class BookingDataProcessor
* booking edits while maintaining the constraint that participants can only be
* assigned to room types that have already been booked.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
*/
private function processRoomAssignment(object $participant, object $bookingData): void
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void
{
if (null === $participant->assignedRoomId) {
return;
@@ -218,15 +344,48 @@ class BookingDataProcessor
}
}
/**
* Processes travel insurance for a participant.
*
* Maps insurance to the participant. Adds new insurances to the booking if they don't exist.
* Insurance can be either individual or package-based, with automatic price-tier adjustment.
*
* @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update
* @param Travel $travelData The travel data containing available insurances
*/
private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{
if (null === $participant->insurance) {
return;
}
$insurance = $participant->insurance;
if (false === isset($bookingData->insurances[$insurance->id])) {
$insuranceToAdd = $travelData->insurances[$insurance->id] ?? null;
if (null !== $insuranceToAdd) {
$bookingData->insurances[$insurance->id] = $insuranceToAdd;
$bookingData->insurances[$insurance->id]->individualPrice[$participant->index] = $insuranceToAdd->price;
}
}
// Only add mapping if insurance exists in booking data
// This can legitimately be false if the insurance is no longer available in current travel data
if (isset($bookingData->insurances[$insurance->id])) {
$bookingData->insurances[$insurance->id]->mapping[] = $participant->index;
}
}
/**
* Removes services and pickups with no participant mappings.
*
* Cleans up unused services to prevent empty services from being sent to the API.
* This includes additional services, transportation services, and pickup locations.
* This includes additional services, transportation services, pickup locations, and insurances.
*
* @param object $bookingData The booking data object to clean up
* @param Booking $bookingData The booking data object to clean up
*/
private function removeUnusedServices(object $bookingData): void
private function removeUnusedServices(Booking $bookingData): void
{
foreach ($bookingData->additionalServices as $service) {
if (0 === count($service->mapping)) {
@@ -251,6 +410,12 @@ class BookingDataProcessor
unset($bookingData->pickupsInbound[$pickup->id]);
}
}
foreach ($bookingData->insurances as $insurance) {
if (0 === count($insurance->mapping)) {
unset($bookingData->insurances[$insurance->id]);
}
}
}
/**
@@ -259,10 +424,13 @@ class BookingDataProcessor
* Only processes participants with status 'F' (active/confirmed participants).
* Updates all personal data fields and communication information.
*
* IMPORTANT: The applicant's address must never be modified. This method updates
* participant addresses independently to ensure applicant data remains intact.
*
* @param array $participants The participants array from the form
* @param object $bookingData The booking data object to update
* @param Booking $bookingData The booking data object to update
*/
private function updateParticipantPersonalData(array $participants, object $bookingData): void
private function updateParticipantPersonalData(array $participants, Booking $bookingData): void
{
foreach ($participants as $participant) {
if ('F' !== $participant->status) {
@@ -278,6 +446,19 @@ class BookingDataProcessor
$bookingData->participants[$participant->index]->weight = $participant->weight;
$bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize;
// Update address if provided
// Create new Address instance to avoid modifying any shared object references
if (null !== $participant->address) {
$newAddress = new Address();
$newAddress->street = $participant->address->street;
$newAddress->postCode = $participant->address->postCode;
$newAddress->city = $participant->address->city;
$newAddress->district = $participant->address->district;
$newAddress->country = $participant->address->country;
$bookingData->participants[$participant->index]->address = $newAddress;
}
if ($participant->email || $participant->mobile) {
if (null === $bookingData->participants[$participant->index]->communication) {
$bookingData->participants[$participant->index]->communication = new Communication();
@@ -288,32 +469,16 @@ class BookingDataProcessor
}
}
/**
* Synchronizes applicant data with first participant's physical characteristics.
*
* The applicant (booking holder) inherits physical data from the first participant.
*
* @param object $bookingData The booking data object to update
*/
private function syncApplicantData(object $bookingData): void
{
if (false !== $firstParticipant = reset($bookingData->participants)) {
$bookingData->applicant->height = $firstParticipant->height;
$bookingData->applicant->weight = $firstParticipant->weight;
$bookingData->applicant->shoeSize = $firstParticipant->shoeSize;
}
}
/**
* Builds the base API payload structure with booking information.
*
* Creates the main structure that will be populated with detailed data sections.
*
* @param object $bookingData The booking data object
* @param Booking $bookingData The booking data object
*
* @return array The base payload structure
*/
private function buildBasePayload(object $bookingData): array
private function buildBasePayload(Booking $bookingData): array
{
return [
'idbuchung' => $bookingData->id,
@@ -348,9 +513,9 @@ class BookingDataProcessor
* Bank account information is required for direct debit payments.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
* @param Booking $bookingData The booking data object
*/
private function addBankAccountToPayload(array &$payload, object $bookingData): void
private function addBankAccountToPayload(array &$payload, Booking $bookingData): void
{
if (null !== $bookingData->bankAccount) {
$payload['zahlung']['bankverbindung'] = [
@@ -365,19 +530,42 @@ class BookingDataProcessor
/**
* Builds the participant list section of the payload.
*
* Includes status and personal data for each participant.
* Includes status, personal data, and wishes (room remarks, license plate) for each participant.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
* @param Booking $bookingData The booking data object
* @param array $participantDtos The participant DTOs from the form (for wishes data)
*/
private function buildParticipantPayload(array &$payload, object $bookingData): void
private function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void
{
foreach ($bookingData->participants as $index => $participant) {
$payload['teilnehmerliste']['teilnehmer'][] = [
$participantPayload = [
'@id' => $index + 1,
'status' => $bookingData->participantsStatus[$index],
...$participant->toPayload(),
];
// Add wishes (room remarks, license plate) from form DTO
if (isset($participantDtos[$index])) {
$dto = $participantDtos[$index];
if (null !== $dto->remarksRoom || null !== $dto->licensePlate) {
$wishes = [];
if (null !== $dto->remarksRoom && '' !== trim($dto->remarksRoom)) {
$wishes['unterbringungswunsch'] = $dto->remarksRoom;
}
if (null !== $dto->licensePlate && '' !== trim($dto->licensePlate)) {
$wishes['beförderungswunsch'] = $dto->licensePlate;
}
if (false === empty($wishes)) {
$participantPayload['wünsche'] = $wishes;
}
}
}
$payload['teilnehmerliste']['teilnehmer'][] = $participantPayload;
}
}
@@ -388,9 +576,9 @@ class BookingDataProcessor
* with participant mappings and quantities.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
* @param Booking $bookingData The booking data object
*/
private function buildServicesPayload(array &$payload, object $bookingData): void
private function buildServicesPayload(array &$payload, Booking $bookingData): void
{
foreach ($bookingData->additionalServices as $service) {
$payload['zusatzleistungen']['zusatzleistung'][] = [
@@ -419,6 +607,20 @@ class BookingDataProcessor
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)),
];
}
// Add insurances with participant mappings
if (false === empty($bookingData->insurances)) {
$payload['versicherungen']['versicherung'] = [];
foreach ($bookingData->insurances as $insurance) {
if (count($insurance->mapping) > 0) {
$payload['versicherungen']['versicherung'][] = [
'@idversicherung' => $insurance->id,
'@anzahl' => count($insurance->mapping),
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)),
];
}
}
}
}
/**
@@ -427,9 +629,9 @@ class BookingDataProcessor
* Only included in payload if there are actual pickup assignments for bus transportation.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
* @param Booking $bookingData The booking data object
*/
private function buildPickupPayload(array &$payload, object $bookingData): void
private function buildPickupPayload(array &$payload, Booking $bookingData): void
{
if (0 < count($bookingData->pickupsOutbound)) {
$payload['zustiege']['zustieg'] = [];
@@ -451,13 +653,16 @@ class BookingDataProcessor
* payment information. The booking type determines whether this is an inquiry validation
* ('Anfrage') or a final booking commit ('Buchung').
*
* @param BookingCreateDto $bookingDto The booking creation form data
* @param BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array The structured payload array for BusProNet API submission
*/
public function createBookingRequestPayload(BookingCreateDto $bookingDto, string $bookingType): array
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
{
// Apply bulk insurance if enabled (modifies DTO in place)
$this->applyBulkInsuranceIfActive($bookingDto);
$firstParticipant = $bookingDto->participants[0];
$payload = [
@@ -802,4 +1007,59 @@ class BookingDataProcessor
return $insuranceMap;
}
/**
* Applies bulk insurance assignment if the applicant has enabled it.
*
* When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method
* assigns the applicant's insurance TYPE to all dependent participants with automatic
* price tier adjustment based on each participant's total cost.
*
* IMPORTANT: In edit mode, bulk insurance only applies to participants who:
* 1. Currently have NO insurance assigned (insurance === null)
* 2. OR whose insurance was already assigned via previous bulk operation
*
* This prevents overriding individually selected insurances that are locked.
*
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
*/
private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void
{
$applicant = $bookingDto->getParticipant(0);
// Check if bulk insurance is enabled and applicant has selected an insurance
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
return;
}
// Get all available insurances from travel data
$availableInsurances = array_values($bookingDto->travel->insurances);
// Use InsuranceMatchingService for proper type-based assignment with price tier matching
$assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants(
$availableInsurances,
$applicant->insurance,
$bookingDto
);
// Apply assignments to dependent participants (skip applicant at index 0)
foreach ($assignments as $index => $insurance) {
if (0 === $index) {
continue; // Skip applicant
}
$participant = $bookingDto->getParticipant($index);
if (null === $participant) {
continue;
}
// In edit mode: only apply bulk insurance if participant has no insurance
// This respects the rule that once assigned, insurance cannot be changed
if ($bookingDto instanceof BookingEditDto && null !== $participant->insurance) {
continue; // Skip participants with existing insurance assignment
}
$participant->insurance = $insurance;
}
}
}
+18
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use App\BusProNet\Constants;
/**
* Represents a booking with participants, services, and pricing information.
*
@@ -192,6 +194,22 @@ class Booking
return null;
}
/**
* Retrieves rental insurance for a specific participant.
*/
public function getRentalInsuranceForParticipant(int $participantIndex): ?Service
{
$rentalInsurances = $this->getAdditionalServicesByGroup(Constants::TOKEN_RENTAL_INSURANCE);
foreach ($rentalInsurances as $service) {
if (in_array($participantIndex, $service->mapping)) {
return $service;
}
}
return null;
}
/**
* Retrieves room assignment for a specific participant.
*
+4
View File
@@ -39,6 +39,10 @@ class PersonalData
public ?\DateTimeImmutable $dateOfBirth = null;
public ?string $remarks = null;
// Wishes (unterbringungswunsch, beförderungswunsch)
public ?string $remarksRoom = null;
public ?string $licensePlate = null;
#[Assert\Valid(groups: ['personal_data'])]
public Address $address;
@@ -174,6 +174,13 @@ class BookingParser extends AbstractParser
$personalData->communication->email = '[email protected]';
}
// Parse wishes (room remarks and license plate)
$wishesNode = $node->filterXPath('//wünsche');
if (0 < $wishesNode->count()) {
$personalData->remarksRoom = $this->getStringOrNullValue($wishesNode->filterXPath('//unterbringungswunsch'));
$personalData->licensePlate = $this->getStringOrNullValue($wishesNode->filterXPath('//beförderungswunsch'));
}
return $personalData;
}
}
@@ -6,7 +6,6 @@ use App\BusProNet\Model\Address;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use Symfony\Component\DomCrawler\Crawler;
use voku\helper\AntiXSS;
class PersonalDataParser extends AbstractParser
{
@@ -58,8 +57,7 @@ class PersonalDataParser extends AbstractParser
$remarksNode = $addressDataNode->filterXPath('//bemerkung');
if (0 < $remarksNode->count()) {
$antiXss = new AntiXSS();
$personalData->remarks = $antiXss->xss_clean($remarksNode->text());
$personalData->remarks = $remarksNode->text();
}
return $personalData;
+9 -8
View File
@@ -3,6 +3,7 @@
namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\XmlLoader\PickupLoader;
@@ -10,7 +11,7 @@ use App\Controller\Traits\BookingDataTrait;
use App\Controller\Traits\HtmxControllerTrait;
use App\Entity\User;
use App\Form\BookingEditType;
use App\Form\Model\BookingEditDto;
use App\Form\Model\BookingDto;
use App\Security\Crypt;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
@@ -32,6 +33,7 @@ class EditController extends AbstractController
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly TravelDataService $travelDataService,
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
@@ -87,7 +89,7 @@ class EditController extends AbstractController
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
// Calculate pricing data for template
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData);
@@ -151,9 +153,7 @@ class EditController extends AbstractController
return $this->render('booking/edit.html.twig', [
'bookingData' => $bookingData,
'bookingEditDto' => $formData,
'travelData' => $travelData,
'mutableData' => $mutableData,
'availabilities' => $availabilities,
'form' => $form->createView(),
'pricingData' => $summary['pricing'],
'participantCount' => $summary['participantCount'],
@@ -203,8 +203,8 @@ class EditController extends AbstractController
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
// Create fresh DTO from booking data
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
// Process form data without validation to capture current state
$form = $this->createForm(BookingEditType::class, $formData, [
@@ -242,6 +242,7 @@ class EditController extends AbstractController
'assignmentCounts' => $roomAssignmentCounts,
'participantPrices' => $participantPrices,
'groupedSelectedRooms' => $groupedSelectedRooms,
'mutableData' => $mutableData,
]
);
@@ -260,11 +261,11 @@ class EditController extends AbstractController
*
* @return array<array{type: string, message: string}> Array of notification messages
*/
private function collectParticipantNotifications(BookingEditDto $bookingEditDto): array
private function collectParticipantNotifications(BookingDto $bookingDto): array
{
$notifications = [];
foreach ($bookingEditDto->participants as $participant) {
foreach ($bookingDto->participants as $participant) {
if ([] !== $participant->notifications) {
foreach ($participant->notifications as $notification) {
$notifications[] = $notification;
@@ -11,7 +11,6 @@ trait BookingDataTrait
{
public function fetchBookingData(string $email, string $password, int $id): Booking|Notification|null
{
// Fetch booking data via API and cache result for a short ttl to check permissions
$cacheKey = sprintf('bpn_booking_%d', $id);
try {
$bookingData = $this->cache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $id) {
+4 -4
View File
@@ -2,7 +2,7 @@
namespace App\Form;
use App\Form\Model\BookingEditDto;
use App\Form\Model\BookingDto;
use App\Form\Service\ParticipantFieldHandlerRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
@@ -27,7 +27,7 @@ class BookingEditType extends AbstractType
public function onPreSetData(FormEvent $event): void
{
/** @var BookingEditDto $data */
/** @var BookingDto $data */
$data = $event->getData();
$form = $event->getForm();
@@ -46,7 +46,7 @@ class BookingEditType extends AbstractType
$form = $event->getForm();
$submittedData = $event->getData();
/** @var BookingEditDto $bookingDto */
/** @var BookingDto $bookingDto */
$bookingDto = $form->getData();
// Process field handlers and synchronize submitted data with cleaned DTO state
@@ -71,7 +71,7 @@ class BookingEditType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingEditDto::class,
'data_class' => BookingDto::class,
]);
}
}
+15 -10
View File
@@ -3,7 +3,7 @@
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\FieldOptionsProviderInterface;
use App\Form\Service\Contract\FieldStateProviderInterface;
@@ -14,6 +14,7 @@ use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -108,7 +109,7 @@ class BookingParticipantType extends AbstractType
/**
* Adds base fields to the form with field states applied.
*/
private function addBaseFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void
private function addBaseFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void
{
// Get field states for base fields
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex);
@@ -151,12 +152,12 @@ class BookingParticipantType extends AbstractType
], $getFieldState('email')))
->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)',
'required' => 0 === $participantIndex,
'required' => false,
'sanitize_html' => true,
], $getFieldState('mobile')))
->add('address', AddressType::class, $this->mergeFieldState([
'label' => 'Adresse',
'required' => 0 === $participantIndex,
'required' => false,
], $getFieldState('address')));
// Add body dimensions with state handling - use shouldIncludeField method
@@ -172,11 +173,11 @@ class BookingParticipantType extends AbstractType
* field states change based on submitted data.
*
* @param FormInterface $form The form to modify
* @param BookingDtoInterface $bookingDto The booking data for context
* @param BookingDto $bookingDto The booking data for context
* @param int $participantIndex The participant index
* @param array<string, mixed> $formData Submitted form data for state calculation
*/
private function removeExcludedFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $formData = []): void
{
foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) {
if ($form->has($fieldName) && !$this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $formData)) {
@@ -188,7 +189,7 @@ class BookingParticipantType extends AbstractType
/**
* Rebuilds all fields with updated states based on submitted data.
*/
private function rebuildFieldsWithStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $submittedData): void
private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $submittedData): void
{
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData);
@@ -220,7 +221,7 @@ class BookingParticipantType extends AbstractType
/**
* Adds all configured dynamic fields to the form with state conditions applied.
*/
private function addDynamicFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void
private function addDynamicFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void
{
$dynamicFields = [
'assignedRoomId' => ChoiceType::class,
@@ -236,10 +237,14 @@ class BookingParticipantType extends AbstractType
'pickup' => ChoiceType::class,
'parking' => CheckboxType::class,
'licensePlate' => TextType::class,
'bulkInsuranceBooking' => CheckboxType::class,
'insurance' => InsuranceChoiceType::class,
];
// Insurance fields only available in create mode (API limitation)
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = InsuranceChoiceType::class;
}
foreach ($dynamicFields as $fieldName => $fieldType) {
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) {
// Check if field should be included in the form at all
+11
View File
@@ -33,6 +33,17 @@ class BankAccountDto
#[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')]
public bool $sepaMandateAccepted = false;
public static function fromBankAccount(\App\BusProNet\Model\BankAccount $bankAccount): static
{
$instance = new static();
$instance->iban = $bankAccount->iban;
$instance->accountHolder = $bankAccount->holder;
$instance->bankName = $bankAccount->bankName;
$instance->sepaMandateAccepted = true;
return $instance;
}
/**
* Returns IBAN formatted with spaces for display (e.g., DE12 3456 7890 1234 5678 90).
*/
+5
View File
@@ -37,6 +37,11 @@ class BookingCreateDto implements BookingDtoInterface
{
}
public function getMode(): string
{
return BookingDtoInterface::MODE_CREATE;
}
/**
* @return array<int, RoomSelectionDto>
*/
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Unified DTO for both booking creation and editing workflows.
*
* This DTO consolidates the previously separate BookingCreateDto and BookingEditDto
* into a single class that handles both modes. All service selections are stored
* in participant DTOs regardless of mode, ensuring consistent data structure and
* simplifying pricing calculations, field handlers, and template rendering.
*/
class BookingDto
{
public const MODE_CREATE = 'create';
public const MODE_EDIT = 'edit';
public int $currentStep = 1;
/**
* @var array<int, RoomSelectionDto>
*/
#[Assert\Valid]
public array $roomSelections = [];
/**
* @var array<int, ParticipantDto>
*/
#[Assert\Valid]
public array $participants = [];
#[Assert\Choice(
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
message: 'Bitte wählen Sie eine gültige Zahlungsart.'
)]
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
public ?BankAccountDto $bankAccount = null;
public ?int $agencyId = null;
/**
* Reference to booking entity (only populated in edit mode).
*/
public ?Booking $booking = null;
public function __construct(public Travel $travel, public int $hotelId)
{
}
public function getMode(): string
{
return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE;
}
/**
* @return array<int, RoomSelectionDto>
*/
public function getSelectedRooms(): array
{
// In edit mode, rooms are fixed - return empty array
if (self::MODE_EDIT === $this->getMode()) {
return [];
}
return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) {
return 0 < $roomSelection->quantity;
});
}
public function getParticipants(): array
{
return $this->participants;
}
public function hasParticipant(int $index): bool
{
return isset($this->participants[$index]);
}
public function getParticipant(int $index): ?ParticipantDto
{
return $this->participants[$index] ?? null;
}
/**
* Determines if this is a family booking based on participant age distribution.
*
* A family booking is defined as:
* - 1 or 2 participants aged 18 or older (adults)
* - At least 1 participant younger than 18 (children)
*/
public function isFamilyBooking(): bool
{
$adults = 0;
$children = 0;
$travelStartDate = $this->travel->dateFrom;
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if (null === $age) {
continue;
}
if ($age >= 18) {
++$adults;
} else {
++$children;
}
}
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
}
public function isCanceled(): bool
{
return null !== $this->booking && 'S' === $this->booking->status;
}
public function isOption(): bool
{
return null !== $this->booking && 'O' === $this->booking->status;
}
#[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])]
public function validateRoomSelection(ExecutionContextInterface $context): void
{
$selectedRooms = $this->getSelectedRooms();
if (0 === count($selectedRooms)) {
$context->buildViolation('Bitte mindestens ein Zimmer/Bett auswählen')
->addViolation();
}
}
#[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void
{
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
return;
}
if (null === $this->bankAccount) {
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
->atPath('bankAccount')
->addViolation();
return;
}
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
$context->buildViolation('Bitte geben Sie Ihre IBAN ein.')
->atPath('bankAccount.iban')
->addViolation();
}
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
$context->buildViolation('Bitte geben Sie den Kontoinhaber ein.')
->atPath('bankAccount.accountHolder')
->addViolation();
}
if (false === $this->bankAccount->sepaMandateAccepted) {
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.')
->atPath('bankAccount.sepaMandateAccepted')
->addViolation();
}
}
}
+10
View File
@@ -14,6 +14,16 @@ namespace App\Form\Model;
*/
interface BookingDtoInterface
{
public const MODE_CREATE = 'create';
public const MODE_EDIT = 'edit';
/**
* Gets the booking mode (create or edit).
*
* @return string One of MODE_CREATE or MODE_EDIT constants
*/
public function getMode(): string;
/**
* Gets all participants in the booking.
*
+12 -2
View File
@@ -21,6 +21,11 @@ class BookingEditDto implements BookingDtoInterface
{
}
public function getMode(): string
{
return BookingDtoInterface::MODE_EDIT;
}
public static function fromBooking(Booking $booking, Travel $travel): static
{
$instance = new static($booking, $travel);
@@ -30,7 +35,9 @@ class BookingEditDto implements BookingDtoInterface
foreach ($booking->participants as $index => $participant) {
/** @var PersonalData $participant */
$participantData = ParticipantDto::fromPersonalData($participant);
// For the first participant (applicant), use applicant data instead of participant data
$personalData = 0 === $index && null !== $booking->applicant ? $booking->applicant : $participant;
$participantData = ParticipantDto::fromPersonalData($personalData);
$participantData->index = $index;
$participantData->courses = $booking
@@ -102,7 +109,10 @@ class BookingEditDto implements BookingDtoInterface
/**
* Gets all selected rooms for the booking (edit context).
*
* @return array<int, RoomSelectionDto> always returns an empty array for edit DTOs unless implemented
* In edit mode, rooms are fixed and not selectable - returns empty array.
* Participant count should be derived from actual participants, not room selections.
*
* @return array<int, RoomSelectionDto>
*/
public function getSelectedRooms(): array
{
+7
View File
@@ -11,6 +11,7 @@ use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])]
#[AppAssert\ApplicantAddress(groups: ['booking_create_step_2'])]
class ParticipantDto
{
/**
@@ -132,6 +133,12 @@ class ParticipantDto
$instance->weight = $personalData->weight;
$instance->shoeSize = $personalData->shoeSize;
// Clone address to prevent shared object references that could cause mutations
$instance->address = null !== $personalData->address ? clone $personalData->address : null;
$instance->remarksRoom = $personalData->remarksRoom;
$instance->licensePlate = $personalData->licensePlate;
return $instance;
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Abstract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldOptionsProviderInterface;
/**
@@ -37,13 +37,13 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
* static options defined in the form type.
*
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array
public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array
{
// Check if we have a provider for this field
if (false === isset($this->fieldOptionProviders[$fieldName])) {
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Abstract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
use App\Form\Service\Contract\FieldStateProviderInterface;
use App\Form\Service\Trait\FormTraversalTrait;
@@ -36,13 +36,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
* condition independently to allow early field exclusion.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the field should be included in the form, false if it should be excluded
*/
public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool
public function shouldIncludeField(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool
{
if (false === isset($this->fieldStateConditions[$fieldName]['hidden'])) {
return true; // No hidden condition means field should be included
@@ -64,13 +64,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
* excluded from the form entirely rather than hidden with CSS.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return array<string, mixed> Symfony form field options for state modifications
*/
public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array
public function getFieldState(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): array
{
if (false === isset($this->fieldStateConditions[$fieldName])) {
return [];
@@ -139,13 +139,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
/**
* Calculates field states for all configured fields at once.
*
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return array<string, array<string, mixed>> Field states indexed by field name
*/
public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array
public function getAllFieldStates(BookingDto $bookingDto, int $participantIndex, array $formData = []): array
{
$allStates = [];
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Abstract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
/**
@@ -55,12 +55,12 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
* if the participant exists at the given index. Returns null if the
* participant doesn't exist, preventing array access errors.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing participants (create or edit)
* @param BookingDto $bookingDto The booking DTO containing participants (create or edit)
* @param int $participantIndex The index of the participant to retrieve
*
* @return object|null The participant object, or null if not found
*/
protected function getParticipant(BookingDtoInterface $bookingDto, int $participantIndex): ?object
protected function getParticipant(BookingDto $bookingDto, int $participantIndex): ?object
{
$participants = $bookingDto->getParticipants();
@@ -129,12 +129,12 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
* processing results.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing)
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param int $participantIndex The participant index being processed
*
* @return array<string, array<string, mixed>> Empty array (no state modifications by default)
*/
public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array
public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array
{
return [];
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface;
*/
class AdditionalServicesMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->additionalServicesMutable;
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -54,13 +54,13 @@ class AgeRangeCondition implements FieldConditionInterface
* checks if it falls within the configured age range. Returns false if
* the participant has no date of birth set.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for age conditions)
*
* @return bool True if the participant's age meets the criteria, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -21,13 +21,13 @@ class ApplicantCondition implements FieldConditionInterface
/**
* Evaluates if the participant is the applicant.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if the participant is the applicant, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// Default: applicant is the first participant (index 0)
return 0 === $participantIndex;
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
use App\Service\ParticipantEligibilityService;
@@ -36,13 +36,13 @@ class BookingEligibilityCondition implements FieldConditionInterface
* Returns true when the participant is INELIGIBLE (should hide service fields).
* Returns false when the participant is eligible (show normal form fields).
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if participant is ineligible (no skipasses available for their age)
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// Invert the eligibility check since conditions typically evaluate to TRUE for "hide"
// isParticipantEligible() returns TRUE when eligible, we need TRUE when INELIGIBLE
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -29,13 +29,13 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface
* For dependent participants, returns true if the applicant has bulk insurance booking enabled,
* regardless of whether an insurance is selected (applies to "no insurance" as well).
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if bulk insurance booking is active for this dependent participant
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// Applicant is never affected by bulk insurance booking (they control it)
if (0 === $participantIndex) {
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -64,13 +64,13 @@ class CompositeCondition implements FieldConditionInterface
* evaluation for optimal performance. The evaluation stops as soon as the
* final result can be determined.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the composite condition is met, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return match ($this->operator) {
self::OPERATOR_AND => $this->evaluateAnd($bookingDto, $participantIndex, $formData),
@@ -168,7 +168,7 @@ class CompositeCondition implements FieldConditionInterface
* Returns false as soon as any condition evaluates to false,
* avoiding unnecessary evaluation of remaining conditions.
*/
private function evaluateAnd(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
private function evaluateAnd(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
foreach ($this->conditions as $condition) {
if (!$condition->evaluate($bookingDto, $participantIndex, $formData)) {
@@ -185,7 +185,7 @@ class CompositeCondition implements FieldConditionInterface
* Returns true as soon as any condition evaluates to true,
* avoiding unnecessary evaluation of remaining conditions.
*/
private function evaluateOr(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
private function evaluateOr(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
foreach ($this->conditions as $condition) {
if ($condition->evaluate($bookingDto, $participantIndex, $formData)) {
@@ -199,7 +199,7 @@ class CompositeCondition implements FieldConditionInterface
/**
* Evaluates NOT logic by inverting the result of the single condition.
*/
private function evaluateNot(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
private function evaluateNot(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return !$this->conditions[0]->evaluate($bookingDto, $participantIndex, $formData);
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -27,17 +27,24 @@ class DateOfBirthProvidedCondition implements FieldConditionInterface
* Checks if the participant exists and has a non-null dateOfBirth property.
* This is a prerequisite for showing age-dependent form fields and services.
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for this condition)
*
* @return bool True if the participant has provided their date of birth, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
$result = null !== $participant && null !== $participant->dateOfBirth;
return null !== $participant && null !== $participant->dateOfBirth;
error_log(sprintf('[DOB Condition] Participant %d: dateOfBirth=%s, result=%s',
$participantIndex,
$participant?->dateOfBirth?->format('Y-m-d') ?? 'NULL',
$result ? 'TRUE' : 'FALSE'
));
return $result;
}
/**
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -60,13 +60,13 @@ class FieldValueCondition implements FieldConditionInterface
* against the expected value using the configured operator. Supports
* both participant-level fields and booking-level fields.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the field value meets the condition criteria, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$fieldValue = $this->getFieldValue($formData, $participantIndex, $bookingDto);
@@ -175,7 +175,7 @@ class FieldValueCondition implements FieldConditionInterface
/**
* Retrieves field value from form data or participant data.
*/
private function getFieldValue(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): mixed
private function getFieldValue(array $formData, int $participantIndex, BookingDto $bookingDto): mixed
{
// First check participant-specific form data
if (isset($formData['participants'][$participantIndex][$this->fieldName])) {
@@ -4,72 +4,36 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingEditDto;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that determines if insurance fields are editable based on booking and travel dates.
* Condition that determines if insurance fields are editable.
*
* Implements time-based mutability constraints for insurance booking in the edit flow:
* - Standard case: Insurance editable up to 30 days before travel date
* - Late booking case: If booking made < 30 days before travel, insurance editable up to 3 days after booking date
* Insurance cannot be modified via the BusProNet API after booking creation,
* so insurance fields are always readonly in edit mode and editable in create mode.
*/
class InsuranceMutabilityCondition implements FieldConditionInterface
{
private const DAYS_BEFORE_TRAVEL_THRESHOLD = 30;
private const DAYS_AFTER_BOOKING_THRESHOLD = 3;
/**
* Evaluates whether the insurance field should be readonly.
*
* Returns true if the field should be readonly (locked), false if editable.
*
* Logic:
* 1. Always editable in create flow
* 2. In edit flow, check standard case: editable if >= 30 days before travel
* 3. In edit flow, check late booking case: editable if within 3 days of booking date
* 4. Otherwise: readonly
* - Create mode: Always editable (return false)
* - Edit mode: Always readonly (return true) - API limitation
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param int $participantIndex The participant index (unused for insurance mutability)
* @param array<string, mixed> $formData Current form data (unused for insurance mutability)
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The participant index (unused)
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if field should be readonly, false if editable
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// Only apply mutability constraints to edit flow
if (!$bookingDto instanceof BookingEditDto) {
return false; // Always editable in create flow
}
$now = \Carbon\Carbon::now()->toDateTimeImmutable();
$travelDate = $bookingDto->travel->dateFrom;
$bookingDate = $bookingDto->booking->bookingDate;
// Calculate days until travel
$daysUntilTravel = $now->diff($travelDate)->days;
$isBeforeTravel = $now < $travelDate;
// Standard case: Editable if >= 30 days before travel
if ($isBeforeTravel && $daysUntilTravel >= self::DAYS_BEFORE_TRAVEL_THRESHOLD) {
return false; // Editable
}
// Late booking case: Check if booking was made < 30 days before travel
$daysFromBookingToTravel = $bookingDate->diff($travelDate)->days;
$wasLateBooking = $daysFromBookingToTravel < self::DAYS_BEFORE_TRAVEL_THRESHOLD;
if ($wasLateBooking) {
// Editable if within 3 days of booking date
$daysSinceBooking = $bookingDate->diff($now)->days;
return $daysSinceBooking > self::DAYS_AFTER_BOOKING_THRESHOLD; // True = readonly (past threshold)
}
// Default: Not editable (readonly)
return true;
// Insurance cannot be updated via API - always readonly in edit mode
return BookingDto::MODE_EDIT === $bookingDto->getMode();
}
/**
@@ -92,10 +56,6 @@ class InsuranceMutabilityCondition implements FieldConditionInterface
*/
public function getDescription(): string
{
return sprintf(
'Insurance is not editable (>= %d days before travel or > %d days after late booking)',
self::DAYS_BEFORE_TRAVEL_THRESHOLD,
self::DAYS_AFTER_BOOKING_THRESHOLD
);
return 'Insurance is not editable in edit mode (API limitation)';
}
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -13,7 +13,7 @@ use App\Form\Service\Contract\FieldConditionInterface;
* This is used to set fields as readonly or disabled if the booking or participant
* is not allowed to be modified (e.g., after a certain workflow step or status).
*
* The logic assumes the BookingDtoInterface or its participant DTOs expose a
* The logic assumes the BookingDto or its participant DTOs expose a
* 'personalDataMutable' property or method. Adjust as needed for your domain.
*/
class MutabilityCondition implements FieldConditionInterface
@@ -21,13 +21,13 @@ class MutabilityCondition implements FieldConditionInterface
/**
* Evaluates if the participant's personal data is mutable.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if the participant's data is mutable, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface;
*/
class PickupsMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->pickupsMutable;
}
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -28,13 +28,13 @@ class RentalSelectionCondition implements FieldConditionInterface
* which would indicate rental services have been selected and body
* dimensions should be required for proper equipment sizing.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if rental services are selected, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// First check submitted form data for rental selections
if (isset($formData['participants'][$participantIndex]['rentals'])) {
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Room;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -35,13 +35,13 @@ class RoomSelectionCondition implements FieldConditionInterface
* Checks both submitted form data and participant DTO data to determine
* if the selected room matches any of the configured room codes.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if room with matching code is selected, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// First check submitted form data for room selection
if (isset($formData['participants'][$participantIndex]['assignedRoomId'])) {
@@ -95,11 +95,11 @@ class RoomSelectionCondition implements FieldConditionInterface
* Finds a room by ID in the available travel rooms.
*
* @param int $roomId The room ID to find
* @param BookingDtoInterface $bookingDto The booking DTO containing travel data
* @param BookingDto $bookingDto The booking DTO containing travel data
*
* @return Room|null The found room or null if not found
*/
private function findRoomById(int $roomId, BookingDtoInterface $bookingDto): ?Room
private function findRoomById(int $roomId, BookingDto $bookingDto): ?Room
{
foreach ($bookingDto->travel->rooms as $room) {
if ($room->id === $roomId) {
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -52,13 +52,13 @@ class ServiceSubTypeCondition implements FieldConditionInterface
* property against the expected value(s). Handles both form data and
* participant DTO data sources.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the service sub-type meets the condition criteria, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$service = $this->getService($formData, $participantIndex, $bookingDto);
@@ -161,7 +161,7 @@ class ServiceSubTypeCondition implements FieldConditionInterface
/**
* Retrieves service from form data or participant data.
*/
private function getService(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): ?Service
private function getService(array $formData, int $participantIndex, BookingDto $bookingDto): ?Service
{
// First check participant-specific form data
if (isset($formData['participants'][$participantIndex][$this->serviceFieldName])) {
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -28,13 +28,13 @@ class SkiPassSelectionCondition implements FieldConditionInterface
* which would indicate a skipass has been selected and rental services
* should be made available with duration filtering applied.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if a skipass is selected, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// First check submitted form data for skipass selection
if (isset($formData['participants'][$participantIndex]['skiPass'])) {
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
@@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface;
*/
class TransportationServicesMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->transportationServicesMutable;
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Contract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
/**
* Interface for evaluating field state conditions.
@@ -29,13 +29,13 @@ interface FieldConditionInterface
* state of the booking, participant data, and submitted form values.
* The result is used to determine field state (enabled/disabled/readonly).
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (may include partial submissions)
*
* @return bool True if the condition is met, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool;
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool;
/**
* Returns field names that trigger re-evaluation of this condition.
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Contract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
/**
* Interface for providing dynamic field options based on context.
@@ -42,13 +42,13 @@ interface FieldOptionsProviderInterface
* static options defined in the form type.
*
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array;
public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array;
/**
* Checks whether a field has option provider support.
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Contract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
/**
* Interface for providing dynamic field state based on conditions.
@@ -38,13 +38,13 @@ interface FieldStateProviderInterface
* condition independently to allow early field exclusion during form building.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the field should be included in the form, false if it should be excluded
*/
public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool;
public function shouldIncludeField(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool;
/**
* Calculates the dynamic state for a specified field.
@@ -65,13 +65,13 @@ interface FieldStateProviderInterface
* - 'attr' => ['class' => 'conditional-field'] - Add CSS classes
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (may include partial submissions)
*
* @return array<string, mixed> Symfony form field options for state modifications, empty if no changes needed
*/
public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array;
public function getFieldState(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): array;
/**
* Checks whether a field has state conditions configured.
@@ -110,11 +110,11 @@ interface FieldStateProviderInterface
* when multiple field states need to be determined simultaneously. It's
* particularly useful during form building and bulk state updates.
*
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return array<string, array<string, mixed>> Field states indexed by field name
*/
public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array;
public function getAllFieldStates(BookingDto $bookingDto, int $participantIndex, array $formData = []): array;
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Contract;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
/**
* Interface for handling dynamic participant form field processing and state modification.
@@ -32,10 +32,10 @@ interface ParticipantFieldHandlerInterface
* Processes the participant field data from submitted form data and updates the DTO.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The participant index being processed
*/
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void;
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void;
/**
* Determines if this handler should process the field based on submitted participant data.
@@ -50,12 +50,12 @@ interface ParticipantFieldHandlerInterface
* to enable/disable/hide fields based on the handler's processing results.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing)
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param int $participantIndex The participant index being processed
*
* @return array<string, array<string, mixed>> Field state modifications indexed by field name
*/
public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array;
public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array;
/**
* Returns field names whose state is affected by this handler's processing.
+4 -17
View File
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\ApplicantCondition;
use App\Form\Service\Condition\BookingEligibilityCondition;
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
use App\Form\Service\Condition\CompositeCondition;
@@ -34,10 +35,11 @@ use App\Service\ParticipantEligibilityService;
class CreateFieldStateProvider extends AbstractFieldStateProvider
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService
private readonly ParticipantEligibilityService $participantEligibilityService,
) {
parent::__construct();
}
/**
* Registers field state conditions for the create workflow.
*
@@ -124,22 +126,7 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
$this->fieldStateConditions['bulkInsuranceBooking'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not($dateOfBirthProvidedCondition), // Hide until date of birth provided
new class() implements \App\Form\Service\Contract\FieldConditionInterface {
public function evaluate(\App\Form\Model\BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
return $participantIndex > 0; // Hide for all participants except applicant
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Participant is not the applicant';
}
}
CompositeCondition::not(new ApplicantCondition()) // Hide for non-applicants
),
];
+26 -15
View File
@@ -11,6 +11,7 @@ use App\Form\Service\Condition\ApplicantCondition;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\InsuranceMutabilityCondition;
use App\Form\Service\Condition\MutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
@@ -43,7 +44,8 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
// Make all personal data fields readonly if not mutable OR if applicant
// Make all personal data fields readonly if participant not mutable
// Note: First participant is now treated as independent from applicant and can be edited
$personalDataFields = [
'firstName',
'lastName',
@@ -55,13 +57,15 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
];
foreach ($personalDataFields as $field) {
$this->fieldStateConditions[$field] = [
'readonly' => CompositeCondition::or(
new ApplicantCondition(),
new MutabilityCondition()
),
'readonly' => CompositeCondition::not(new MutabilityCondition()),
];
}
// Address fields - readonly if participant not mutable
$this->fieldStateConditions['address'] = [
'readonly' => CompositeCondition::not(new MutabilityCondition()),
];
// Conditional visibility for service fields (same as create flow)
$rentalCondition = new RentalSelectionCondition();
$skiPassCondition = new SkiPassSelectionCondition();
@@ -72,33 +76,40 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($rentalCondition),
];
// Age-dependent service fields - hidden until birth date provided
// Age-dependent service fields - hidden until birth date provided (except for applicant who always has DOB)
// Also readonly if services not mutable
// For applicant in edit mode: DOB is always available (patched from booking), so never hide
$hideUntilDobCondition = CompositeCondition::and(
CompositeCondition::not($dateOfBirthProvidedCondition),
CompositeCondition::not(new ApplicantCondition()) // Don't hide for applicant
);
$this->fieldStateConditions['courses'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $additionalServicesMutabilityCondition,
];
$this->fieldStateConditions['additionalServices'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $additionalServicesMutabilityCondition,
];
$this->fieldStateConditions['board'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $additionalServicesMutabilityCondition,
];
// Skipass - hidden until birth date, readonly if services not mutable
// Skipass - hidden until birth date (except applicant), readonly if services not mutable
$this->fieldStateConditions['skiPass'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $additionalServicesMutabilityCondition,
];
// Rentals - shown only when skipass selected, readonly if services not mutable
// For applicant: only check skipass, not DOB
$this->fieldStateConditions['rentals'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not($dateOfBirthProvidedCondition),
$hideUntilDobCondition,
CompositeCondition::not($skiPassCondition)
),
'readonly' => $additionalServicesMutabilityCondition,
@@ -110,14 +121,14 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
'readonly' => $additionalServicesMutabilityCondition,
];
// Transportation fields - hidden until birth date, readonly if transportation not mutable
// Transportation fields - hidden until birth date (except applicant), readonly if transportation not mutable
$this->fieldStateConditions['transportationOutbound'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $transportationServicesMutabilityCondition,
];
$this->fieldStateConditions['transportationInbound'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => $hideUntilDobCondition,
'readonly' => $transportationServicesMutabilityCondition,
];
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -84,10 +84,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -122,7 +122,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
*
* @param array $selectedServices List of currently selected services
* @param array $availableServices List of all available additional services
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return array Filtered array of valid service selections
@@ -130,7 +130,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
private function filterValidServiceSelections(
array $selectedServices,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): array {
$validSelections = [];
@@ -156,7 +156,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
*
* @param mixed $selectedService The selected service to validate
* @param array $availableServices Array of available services
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the service is valid for the participant, false otherwise
@@ -164,7 +164,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): bool {
// Find the service in available services
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -53,10 +53,10 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
* 4. Updates the participant's assignedRoomId property
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -49,7 +49,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
@@ -72,7 +72,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
private function filterValidServiceSelections(
array $selectedServices,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): array {
$validSelections = [];
@@ -93,7 +93,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): bool {
$service = $this->findServiceInAvailableServices($selectedService, $availableServices);
@@ -4,30 +4,23 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
/**
* Handles bulk insurance booking for the applicant (first participant).
* Handles bulk insurance booking checkbox for the applicant (first participant).
*
* When the applicant enables bulk insurance booking, their selected insurance type
* (subType + familyInsurance) is automatically assigned to all participants with
* automatic price tier adjustment based on each participant's individual travel price.
* This handler only manages the checkbox state - it does NOT assign insurance to
* dependent participants. The actual bulk insurance assignment happens in the
* BookingDataProcessor during API submission, keeping the form layer clean and
* avoiding cross-participant modifications in field handlers.
*
* This handler processes the bulkInsuranceBooking checkbox state and triggers
* insurance assignment to dependent participants when enabled.
*
* Dependencies: insurance (applicant must have insurance selected before enabling bulk)
* The checkbox state is used by:
* - Templates: To display "wie Anmelder" for dependent participants
* - Processors: To apply applicant's insurance to all participants before API submission
*/
class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
) {
}
public function getFieldName(): string
{
return 'bulkInsuranceBooking';
@@ -35,8 +28,8 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
public function getDependencies(): array
{
// Depends on insurance field to ensure insurance is selected before bulk assignment
return ['insurance'];
// No dependencies - just manages checkbox state
return [];
}
/**
@@ -58,120 +51,22 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
/**
* Processes the bulk insurance booking checkbox for the applicant.
*
* When bulk insurance is enabled and applicant has insurance selected,
* assigns the same insurance type to all participants based on their
* individual pricing and eligibility criteria.
* This handler ONLY stores the checkbox state. The actual insurance assignment
* to dependent participants is handled by BookingDataProcessor during API submission.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex The index of the participant (must be 0)
*/
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
if (null === $participant) {
return;
}
// Get checkbox value from submitted data
// Get checkbox value from submitted data and store it
$bulkInsuranceBooking = $this->getFieldValue($submittedData, $this->getFieldName());
$isBulkEnabled = (bool) $bulkInsuranceBooking;
// Store checkbox state
$participant->bulkInsuranceBooking = $isBulkEnabled;
// If bulk insurance is enabled and applicant has insurance, assign to all participants
if (true === $isBulkEnabled && null !== $participant->insurance) {
$this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance);
}
// If bulk insurance was CHANGED from enabled to disabled, clear dependent participants' insurances
// Don't clear if it was never enabled (to allow independent insurance selection)
if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) {
$this->clearDependentParticipantsInsurance($bookingDto);
}
}
/**
* Applies the applicant's insurance type to all participants with automatic price tier adjustment.
*
* Uses InsuranceMatchingService to find the appropriate price tier for each participant
* based on their individual travel price and eligibility criteria.
*
* @param BookingCreateDto $bookingDto The booking DTO with all participants
* @param object $applicantInsurance The insurance selected by the applicant
*/
private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void
{
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Get insurance assignments for all participants
$assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants(
$availableInsurances,
$applicantInsurance,
$bookingDto
);
// Apply assignments to participants (skip applicant index 0, they keep their selection)
foreach ($assignments as $index => $insurance) {
if (0 === $index) {
continue; // Skip applicant
}
$participant = $bookingDto->getParticipant($index);
if (null !== $participant) {
$participant->insurance = $insurance;
}
}
}
/**
* Clears insurance selections for dependent participants when bulk booking is disabled.
*
* @param BookingCreateDto $bookingDto The booking DTO with all participants
*/
private function clearDependentParticipantsInsurance(BookingCreateDto $bookingDto): void
{
foreach ($bookingDto->getParticipants() as $index => $participant) {
if (0 === $index) {
continue; // Skip applicant
}
$participant->insurance = null;
}
}
/**
* Checks if bulk insurance was previously enabled by checking if dependent participants
* have the same insurance type as the applicant.
*
* This prevents clearing independent insurance selections when the checkbox is simply unchecked
* without ever having been enabled.
*
* @param BookingCreateDto $bookingDto The booking DTO with all participants
*
* @return bool True if bulk was previously active (dependent participants have matching insurance)
*/
private function wasBulkInsurancePreviouslyEnabled(BookingCreateDto $bookingDto): bool
{
$applicant = $bookingDto->participants[0] ?? null;
if (null === $applicant || null === $applicant->insurance) {
return false;
}
// Check if any dependent participant has insurance that matches the applicant
// If so, bulk was likely previously enabled
foreach ($bookingDto->participants as $index => $participant) {
if (0 === $index) {
continue; // Skip applicant
}
if (null !== $participant->insurance) {
// If any dependent has insurance, assume bulk was previously enabled
return true;
}
}
return false;
$participant->bulkInsuranceBooking = (bool) $bulkInsuranceBooking;
}
}
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -77,10 +77,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* no longer appropriate for the participant's age are automatically removed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -111,7 +111,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
*
* @param array $selectedServices List of currently selected courses
* @param array $availableServices List of all available courses
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return array Filtered array of valid course selections
@@ -119,7 +119,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
private function filterValidServiceSelections(
array $selectedServices,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): array {
$validSelections = [];
@@ -142,7 +142,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
*
* @param mixed $selectedService The selected course to validate
* @param array $availableServices Array of available courses
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the course is valid for the participant, false otherwise
@@ -150,7 +150,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): bool {
// Find the service in available services
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -53,10 +53,10 @@ class ParticipantDateOfBirthFieldHandler extends AbstractParticipantFieldHandler
* 4. Updates the participant's dateOfBirth property
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -7,7 +7,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
@@ -75,12 +75,14 @@ class ParticipantFieldHandlerRegistry
* automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values
* @param BookingDto $bookingDto The booking DTO to update with processed field values
*
* @return array<string, mixed> The synchronized submitted data reflecting DTO changes
*/
public function processFieldsAndSync(array $submittedData, BookingDtoInterface $bookingDto): array
public function processFieldsAndSync(array $submittedData, BookingDto $bookingDto): array
{
error_log(sprintf('[ProcessFieldsAndSync] Mode: %s', $bookingDto->getMode()));
// Process all field handlers to clean the DTO
$this->processFields($submittedData, $bookingDto);
@@ -105,9 +107,9 @@ class ParticipantFieldHandlerRegistry
* family detection which needs all participants' ages to be processed first).
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values (create or edit)
* @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit)
*/
public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void
public function processFields(array $submittedData, BookingDto $bookingDto): void
{
// Early return if no participant data exists in submission
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
@@ -245,15 +247,18 @@ class ParticipantFieldHandlerRegistry
* that the form continues processing with cleaned data rather than the original
* submitted data that may contain invalid selections.
*
* In edit mode, this also adds the applicant's personal data to the submitted data
* for participant 0, ensuring those fields are bound to the DTO by handleRequest.
*
* The synchronization is generic and works with any field handlers by examining
* the current DTO state and updating the corresponding submitted data fields.
*
* @param array<string, mixed> $submittedData The original submitted form data
* @param BookingDtoInterface $bookingDto The DTO with cleaned data from field handlers
* @param BookingDto $bookingDto The DTO with cleaned data from field handlers
*
* @return array<string, mixed> Updated submitted data reflecting DTO state
*/
private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array
private function syncSubmittedDataWithDto(array $submittedData, BookingDto $bookingDto): array
{
// Ensure participants array exists in submitted data
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
@@ -272,7 +277,7 @@ class ParticipantFieldHandlerRegistry
}
// Update participant data to match cleaned DTO state
$submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant);
$submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant, $index, $bookingDto);
}
return $submittedData;
@@ -285,13 +290,20 @@ class ParticipantFieldHandlerRegistry
* any changes made by field handlers. It automatically detects which fields have
* been processed by checking against registered handlers.
*
* In edit mode for participant 0 (applicant), this also adds the personal data fields
* that were patched from the applicant, ensuring they're bound to the DTO.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param ParticipantDto $participant The cleaned participant DTO
* @param int $index The participant index
* @param BookingDto $bookingDto The booking DTO for mode detection
*
* @return array<string, mixed> Updated participant data with synchronized field values
*/
private function syncParticipantData(array $participantData, ParticipantDto $participant): array
private function syncParticipantData(array $participantData, ParticipantDto $participant, int $index, BookingDto $bookingDto): array
{
error_log(sprintf('[Sync] Participant %d fields in submission: %s', $participant->index ?? -1, implode(', ', array_keys($participantData))));
// Sync fields for all registered handlers
foreach ($this->handlers as $fieldName => $handler) {
// Only sync fields that were in the original submission
@@ -299,6 +311,7 @@ class ParticipantFieldHandlerRegistry
if (property_exists($participant, $fieldName) && array_key_exists($fieldName, $participantData)) {
$dtoValue = $participant->{$fieldName};
$participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue);
error_log(sprintf('[Sync] Participant %d: synced %s', $participant->index ?? -1, $fieldName));
}
}
@@ -362,6 +375,17 @@ class ParticipantFieldHandlerRegistry
return $value->id;
}
// Handle Address objects -> convert to array of properties
if ($value instanceof \App\BusProNet\Model\Address) {
return [
'street' => $value->street,
'postCode' => $value->postCode,
'city' => $value->city,
'district' => $value->district,
'country' => $value->country,
];
}
// Handle DateTimeInterface -> convert to string format
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
@@ -9,7 +9,7 @@ use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingEditDto;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
@@ -82,11 +82,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
protected function registerFieldOptionProviders(): void
{
// Room assignment field provider (available for both create and edit workflows)
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) {
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []) {
$choiceLoader = null;
$disabled = false;
if ($bookingDto instanceof BookingCreateDto) {
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Create context: use selected rooms from step 1
$choiceLoader = $this->roomChoiceLoaderFactory->createForCreate(
$bookingDto->participants,
@@ -95,7 +95,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
);
// Disable when only one room type selected (auto-assigned)
$disabled = 1 === count($bookingDto->getSelectedRooms());
} elseif ($bookingDto instanceof BookingEditDto) {
} elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// Edit context: use already-booked rooms from booking
$choiceLoader = $this->roomChoiceLoaderFactory->createForEdit(
$bookingDto->participants,
@@ -121,7 +121,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
};
// Courses field provider - provides age-appropriate courses from travel data
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['courses'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
@@ -156,7 +156,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Additional services field provider - provides age-appropriate additional services with mandatory pre-selection
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['additionalServices'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zusatzleistungen',
'multiple' => true,
'expanded' => true,
@@ -199,7 +199,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Board field provider - provides age-appropriate board options from travel data
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['board'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Verpflegung',
'multiple' => true,
'expanded' => true,
@@ -229,7 +229,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Leihmaterial',
'multiple' => true,
'expanded' => true,
@@ -268,7 +268,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Rental insurance field provider - provides rental insurance options when rental services are selected
$this->fieldOptionProviders['rentalInsurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)),
'required' => false,
'property_path' => 'rentalInsuranceSelected',
@@ -276,7 +276,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// License plate field provider - provides text input for vehicle license plate when parking is selected
$this->fieldOptionProviders['licensePlate'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['licensePlate'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Kennzeichen',
'required' => false,
'attr' => [
@@ -288,7 +288,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Skipass',
'multiple' => false,
'expanded' => true,
@@ -323,7 +323,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms)
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Wünsche oder Anmerkungen zum Zimmer',
'required' => false,
'sanitize_html' => true,
@@ -335,7 +335,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Transportation field providers - handles outbound/inbound transportation and pickup selection
// Outbound Transportation
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Hinfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
@@ -366,7 +366,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Inbound Transportation
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rückfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
@@ -398,7 +398,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Pickup (conditional - only shown when either transportation direction is bus)
// Uses outbound pickups list, applies to both directions
$this->fieldOptionProviders['pickup'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['pickup'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zu- und Ausstieg',
'choices' => $bookingDto->travel->pickupsOutbound,
'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup),
@@ -411,13 +411,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Parking (conditional - only shown when outbound transportation is PKW)
// Simple checkbox since there's only ever one parking type
$this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['parking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)),
'required' => false,
];
// Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants)
$this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
// Only registered in create mode - insurance cannot be modified in edit mode due to API limitation
$this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Für alle Teilnehmer buchen',
'required' => false,
'attr' => [
@@ -428,7 +429,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Insurance field provider - provides age and eligibility filtered insurances for participants
$this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
// Only registered in create mode - insurance cannot be modified in edit mode due to API limitation
$this->fieldOptionProviders['insurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Reiseversicherung',
'multiple' => false,
'expanded' => true,
@@ -461,12 +463,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
* is only available for the exact duration of the skipass.
*
* @param array $rentals Array of rental Service objects to filter
* @param BookingDtoInterface $bookingDto The booking DTO containing participant data
* @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant to evaluate
*
* @return array Filtered array of rentals matching skipass duration
*/
private function filterRentalsBySkiPassDuration(array $rentals, BookingDtoInterface $bookingDto, int $participantIndex): array
private function filterRentalsBySkiPassDuration(array $rentals, BookingDto $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || null === $participant->skiPass) {
@@ -586,12 +588,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
* Checks if a service should be rendered as read-only due to unavailability.
*
* @param Service $service The service to check
* @param BookingDtoInterface $bookingDto The booking DTO containing participant data
* @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant currently selecting services
*
* @return bool True if the service should be read-only due to unavailability
*/
private function isServiceUnavailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (!$bookingDto instanceof BookingCreateDto) {
// For non-create workflows, don't apply availability restrictions
@@ -609,12 +611,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
* returns empty array to be handled by field visibility conditions.
*
* @param array $services Array of Service objects to filter
* @param BookingDtoInterface $bookingDto The booking DTO containing participant data
* @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant to evaluate
*
* @return array Filtered array of available services
*/
private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
private function filterServicesByAgeConstraints(array $services, BookingDto $bookingDto, int $participantIndex): array
{
$ageEvaluator = new ServiceAgeEvaluator();
@@ -664,12 +666,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
/**
* Gets eligible insurances for a participant based on eligibility criteria.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing travel and participant data
* @param BookingDto $bookingDto The booking DTO containing travel and participant data
* @param int $participantIndex The index of the participant to get eligible insurances for
*
* @return array Array of eligible insurance objects filtered by age, family status, and other constraints
*/
private function getEligibleInsurances(BookingDtoInterface $bookingDto, int $participantIndex): array
private function getEligibleInsurances(BookingDto $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
@@ -682,11 +684,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// They are only available as part of packages
$availableInsurances = array_filter($availableInsurances, fn ($insurance) => !$insurance->complementary);
// Only apply insurance filtering for BookingCreateDto (creation workflow)
if (!$bookingDto instanceof BookingCreateDto) {
return $availableInsurances;
}
// Use insurance matching service to filter based on eligibility criteria
return $this->insuranceMatchingService->getEligibleInsurances(
$availableInsurances,
@@ -5,8 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
@@ -112,13 +111,13 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* participant data and automatically reassigns to the correct price tier if needed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
if (null === $participant) {
return;
}
@@ -206,12 +205,12 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* Currently no field state modifications are needed for insurance selection.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing)
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param int $participantIndex The participant index being processed
*
* @return array<string, array<string, mixed>> Empty array - no field state modifications
*/
public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array
public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array
{
return [];
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -69,10 +69,10 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle
* the license plate is automatically cleared to maintain data consistency.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -7,7 +7,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -46,7 +46,7 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
return true; // Always process for state changes
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
@@ -95,11 +95,11 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
* Gets the first (and typically only) parking service for pricing calculation.
* Returns null if no parking services are available.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing travel data
* @param BookingDto $bookingDto The booking DTO containing travel data
*
* @return Service|null The parking service object, or null if not found
*/
private function findParkingService(BookingDtoInterface $bookingDto): ?Service
private function findParkingService(BookingDto $bookingDto): ?Service
{
$parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true);
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -35,7 +35,7 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler
return true; // Always process to handle clearing pickup
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -41,10 +41,10 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
* the normalization of empty strings to null values.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -74,10 +74,10 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* is no longer appropriate for the participant's age, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -118,11 +118,11 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* Gets the first (and typically only) rental insurance service.
* Returns null if no rental insurance services are available.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing travel data
* @param BookingDto $bookingDto The booking DTO containing travel data
*
* @return Service|null The rental insurance service object, or null if not found
*/
private function findRentalInsuranceService(BookingDtoInterface $bookingDto): ?Service
private function findRentalInsuranceService(BookingDto $bookingDto): ?Service
{
$rentalInsuranceServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true);
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -57,7 +57,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
@@ -108,7 +108,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
private function filterValidServiceSelections(
array $selectedServices,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): array {
$validSelections = [];
@@ -129,7 +129,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): bool {
$service = $this->findServiceInAvailableServices($selectedService, $availableServices);
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -79,10 +79,10 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* age or exceeds the travel date range, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $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, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -117,7 +117,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
*
* @param mixed $selectedService The selected skipass to validate
* @param array $availableServices Array of available skipasses
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the skipass is valid for the participant, false otherwise
@@ -125,7 +125,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
BookingDto $bookingDto,
int $participantIndex,
): bool {
// Find the service in available services
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -41,7 +41,7 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
@@ -6,7 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
@@ -41,7 +41,7 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
+3 -3
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
/**
* Evaluates service availability based on participant age constraints.
@@ -40,12 +40,12 @@ class ServiceAgeEvaluator
* age at the time of travel, not their current age.
*
* @param Service $service The service to evaluate
* @param BookingDtoInterface $bookingDto The booking containing participant data
* @param BookingDto $bookingDto The booking containing participant data
* @param int $participantIndex The index of the participant to evaluate
*
* @return bool True if the service is available for the participant
*/
public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool
public function isServiceAvailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service\Trait;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use Symfony\Component\Form\FormInterface;
/**
@@ -17,17 +17,17 @@ use Symfony\Component\Form\FormInterface;
trait FormTraversalTrait
{
/**
* Gets the BookingDtoInterface from the root of the form tree.
* Gets the BookingDto from the root of the form tree.
*
* This helper method traverses up the form tree to find the root form
* and extracts the BookingDtoInterface data. This is shared logic used
* and extracts the BookingDto data. This is shared logic used
* by both create and edit participant forms.
*
* @param FormInterface $form The form to start traversing from
*
* @return BookingDtoInterface|null The booking DTO or null if not found
* @return BookingDto|null The booking DTO or null if not found
*/
public function getBookingDtoFromForm(FormInterface $form): ?BookingDtoInterface
public function getBookingDtoFromForm(FormInterface $form): ?BookingDto
{
// Traverse up the form tree to get the root form's data
$rootForm = $form;
@@ -37,6 +37,6 @@ trait FormTraversalTrait
$data = $rootForm->getData();
return $data instanceof BookingDtoInterface ? $data : null;
return $data instanceof BookingDto ? $data : null;
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Model;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
@@ -22,7 +22,7 @@ readonly class InsuranceEligibilityCriteria
public \DateTimeImmutable $bookingDate,
public float $travelPrice,
public int $travelDurationDays,
public BookingCreateDto $booking,
public BookingDto $booking,
) {
}
}
+65 -25
View File
@@ -9,7 +9,7 @@ use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
@@ -22,17 +22,18 @@ use App\Form\Model\ParticipantDto;
class BookingPriceCalculatorService
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService
private readonly ParticipantEligibilityService $participantEligibilityService,
) {
}
/**
* Calculates comprehensive pricing breakdown for a booking.
*
* @param BookingDtoInterface $bookingDto The booking data to calculate pricing for
* @param BookingDto $bookingDto The booking data to calculate pricing for
*
* @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown
*/
public function getPricingBreakdown(BookingDtoInterface $bookingDto): array
public function getPricingBreakdown(BookingDto $bookingDto): array
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
$servicePricing = $this->calculateServicePricing($bookingDto);
@@ -48,11 +49,11 @@ class BookingPriceCalculatorService
/**
* Calculates pricing for all selected rooms.
*
* @param BookingDtoInterface $bookingDto The booking data containing room selections
* @param BookingDto $bookingDto The booking data containing room selections
*
* @return array Array of room pricing data with labels, quantities, and totals
*/
public function calculateRoomPricing(BookingDtoInterface $bookingDto): array
public function calculateRoomPricing(BookingDto $bookingDto): array
{
$roomPricing = [];
@@ -95,11 +96,11 @@ class BookingPriceCalculatorService
*
* Only includes services from eligible participants (those with available skipasses for their age).
*
* @param BookingDtoInterface $bookingDto The booking data containing participants and their service selections
* @param BookingDto $bookingDto The booking data containing participants and their service selections
*
* @return array Array of service groups with each group containing services of the same subtype
*/
public function calculateServicePricing(BookingDtoInterface $bookingDto): array
public function calculateServicePricing(BookingDto $bookingDto): array
{
$participants = $bookingDto->getParticipants();
@@ -132,11 +133,11 @@ class BookingPriceCalculatorService
/**
* Calculates the grand total for the entire booking.
*
* @param BookingDtoInterface $bookingDto The booking data to calculate total for
* @param BookingDto $bookingDto The booking data to calculate total for
*
* @return float The grand total price
*/
public function calculateGrandTotal(BookingDtoInterface $bookingDto): float
public function calculateGrandTotal(BookingDto $bookingDto): float
{
$roomTotal = $this->calculateRoomTotal($bookingDto);
$serviceTotal = $this->calculateServiceTotal($bookingDto);
@@ -147,7 +148,7 @@ class BookingPriceCalculatorService
/**
* Calculates total price for all rooms.
*/
public function calculateRoomTotal(BookingDtoInterface $bookingDto): float
public function calculateRoomTotal(BookingDto $bookingDto): float
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
@@ -157,7 +158,7 @@ class BookingPriceCalculatorService
/**
* Calculates total price for all services.
*/
public function calculateServiceTotal(BookingDtoInterface $bookingDto): float
public function calculateServiceTotal(BookingDto $bookingDto): float
{
$servicePricing = $this->calculateServicePricing($bookingDto);
@@ -194,12 +195,12 @@ class BookingPriceCalculatorService
* This method calculates the complete price breakdown for a single participant,
* including their room allocation (full room price) and all selected services.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
* @param BookingDto $bookingDto The booking data containing all participants
* @param int $participantIndex The index of the participant to calculate for
*
* @return float The total price for the specified participant
*/
public function calculateIndividualParticipantPrice(BookingDtoInterface $bookingDto, int $participantIndex): float
public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
@@ -221,8 +222,8 @@ class BookingPriceCalculatorService
}
}
// Add service prices for this participant
$totalPrice += $this->calculateParticipantServiceTotal($participant);
// Add service prices for this participant (with booking context for bulk insurance)
$totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto);
return $totalPrice;
}
@@ -230,11 +231,11 @@ class BookingPriceCalculatorService
/**
* Calculates individual prices for all participants in a booking.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
* @param BookingDto $bookingDto The booking data containing all participants
*
* @return array Array indexed by participant index containing individual prices
*/
public function calculateAllParticipantIndividualPrices(BookingDtoInterface $bookingDto): array
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
{
if (false === $bookingDto instanceof BookingCreateDto) {
return [];
@@ -256,12 +257,12 @@ class BookingPriceCalculatorService
* This method is used for insurance eligibility filtering to avoid circular dependency
* where insurance selection affects travel price which affects insurance eligibility.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
* @param BookingDto $bookingDto The booking data containing all participants
* @param int $participantIndex The index of the participant to calculate for
*
* @return float The total price for the specified participant excluding insurance
*/
public function calculateIndividualParticipantPriceExcludingInsurance(BookingDtoInterface $bookingDto, int $participantIndex): float
public function calculateIndividualParticipantPriceExcludingInsurance(BookingDto $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
@@ -300,11 +301,11 @@ class BookingPriceCalculatorService
*
* Note: Transportation discounts will be handled generically by groupServicesBySubtype as "Beförderung - Rabatt"
*
* @param BookingDtoInterface $bookingDto The booking data containing participants
* @param BookingDto $bookingDto The booking data containing participants
*
* @return array Array of transportation line items (Zustieg, Parkplatz)
*/
private function aggregateTransportationServices(BookingDtoInterface $bookingDto): array
private function aggregateTransportationServices(BookingDto $bookingDto): array
{
$participants = $bookingDto->getParticipants();
@@ -529,10 +530,11 @@ class BookingPriceCalculatorService
*
* @param ParticipantDto $participant The participant to calculate services for
* @param bool $includeInsurance Whether to include insurance pricing (default: true)
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution
*
* @return float The total service cost for this participant
*/
private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float
private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true, ?BookingDto $bookingDto = null): float
{
$serviceTotal = 0.0;
@@ -545,8 +547,10 @@ class BookingPriceCalculatorService
$serviceTotal += $participant->rentalInsurance->price;
}
if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) {
$serviceTotal += $participant->insurance->price;
// Get effective insurance (considering bulk insurance for dependent participants)
$effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto);
if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) {
$serviceTotal += $effectiveInsurance->price;
}
// Transportation services
@@ -630,4 +634,40 @@ class BookingPriceCalculatorService
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
}
/**
* Gets the effective insurance for a participant, considering bulk insurance assignment.
*
* When bulk insurance is active and the participant is a dependent (index > 0),
* returns the applicant's insurance. Otherwise returns the participant's own insurance.
*
* This method is used for pricing calculations to show correct prices when bulk
* insurance is enabled, even though the actual assignment happens in the processor.
*
* @param ParticipantDto $participant The participant to get insurance for
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check
*
* @return Insurance|null The effective insurance for pricing purposes
*/
private function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance
{
// If no booking context, use participant's own insurance
if (null === $bookingDto) {
return $participant->insurance;
}
// Applicant always uses their own insurance
if (0 === $participant->index) {
return $participant->insurance;
}
// Check if bulk insurance is active
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant || false === $applicant->bulkInsuranceBooking) {
return $participant->insurance;
}
// Bulk insurance is active - use applicant's insurance for dependent participants
return $applicant->insurance;
}
}
+56 -7
View File
@@ -7,6 +7,7 @@ use App\BusProNet\Model\Travel;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\NoRoomsAvailableException;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -15,6 +16,7 @@ class BookingService
{
public const BOOKING_CREATE_KEY = 'booking_create';
public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot';
public const BOOKING_EDIT_KEY = 'booking_edit';
public function __construct(
private readonly TravelDataService $travelDataService,
@@ -81,6 +83,46 @@ class BookingService
throw new BookingSessionNotFoundException();
}
/**
* Saves the booking DTO to the session.
*
* @param Request $request The HTTP request with session
* @param object $bookingDto The booking DTO to persist
* @param string $mode The booking mode (create/edit)
*/
public function saveBookingDto(Request $request, object $bookingDto, string $mode): void
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
$request->getSession()->set($key, $bookingDto);
}
/**
* Retrieves the booking DTO from the session.
*
* @param Request $request The HTTP request containing session data
* @param string $mode The booking mode (create/edit)
*
* @return object|null The booking DTO from session or null if not found
*/
public function getBookingDto(Request $request, string $mode): ?object
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
return $request->getSession()->get($key);
}
/**
* Clears the booking DTO from the session.
*
* @param Request $request The HTTP request with session
* @param string $mode The booking mode (create/edit)
*/
public function clearBookingDto(Request $request, string $mode): void
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
$request->getSession()->remove($key);
}
/**
* Saves the booking creation DTO to the session.
*
@@ -92,7 +134,7 @@ class BookingService
*/
public function saveBookingCreateDto(Request $request, BookingCreateDto $bookingCreateDto): void
{
$request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto);
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
}
/**
@@ -209,10 +251,10 @@ class BookingService
*
* @return array<int, int> an array where the key is the room ID and the value is the count of assigned participants
*/
public function getRoomAssignmentCounts(BookingCreateDto $bookingCreateDto): array
public function getRoomAssignmentCounts(BookingDto $bookingDto): array
{
$counts = [];
foreach ($bookingCreateDto->participants as $participant) {
foreach ($bookingDto->getParticipants() as $participant) {
if (null !== $participant->assignedRoomId) {
$counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1;
}
@@ -226,11 +268,18 @@ class BookingService
*
* @return array{selectedRooms: array, participantCount: int, pricing: array}
*/
public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array
public function getRoomSummaryAndParticipantCount(BookingDto $bookingDto): array
{
$selectedRooms = $bookingCreateDto->getSelectedRooms();
$participantCount = $this->getParticipantsCount($selectedRooms, $bookingCreateDto->travel);
$pricing = $this->priceCalculator->getPricingBreakdown($bookingCreateDto);
$selectedRooms = $bookingDto->getSelectedRooms();
// In edit mode, count actual participants; in create mode, calculate from room selections
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
$participantCount = count($bookingDto->getParticipants());
} else {
$participantCount = $this->getParticipantsCount($selectedRooms, $bookingDto->travel);
}
$pricing = $this->priceCalculator->getPricingBreakdown($bookingDto);
return [
'selectedRooms' => $selectedRooms,
+17 -10
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Traits\SortByPriceTrait;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Model\InsuranceEligibilityCriteria;
use Carbon\Carbon;
@@ -21,6 +22,7 @@ use Carbon\Carbon;
class InsuranceMatchingService
{
use SortByPriceTrait;
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
@@ -31,11 +33,11 @@ class InsuranceMatchingService
*
* @param array<Insurance> $insurances Available insurances to filter
* @param ParticipantDto $participant The participant to match insurances for
* @param BookingCreateDto $booking The booking context for additional criteria
* @param BookingDto $booking The booking context for additional criteria
*
* @return array<Insurance> Filtered array of eligible insurances
*/
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array
{
$criteria = $this->createEligibilityCriteria($participant, $booking);
@@ -61,11 +63,11 @@ class InsuranceMatchingService
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $currentInsurance The currently selected insurance
* @param ParticipantDto $participant The participant to reassign for
* @param BookingCreateDto $booking The booking context
* @param BookingDto $booking The booking context
*
* @return Insurance|null The reassigned insurance or null if no suitable match found
*/
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking): ?Insurance
{
// Group insurances of the same type
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance);
@@ -90,13 +92,13 @@ class InsuranceMatchingService
*
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingCreateDto $booking The booking with all participants
* @param BookingDto $booking The booking with all participants
*
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*
* @internal Reserved for future feature implementation
*/
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingCreateDto $booking): array
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingDto $booking): array
{
$assignments = [];
@@ -118,7 +120,7 @@ class InsuranceMatchingService
* This method performs early validation before creating the criteria object
* to avoid unnecessary object instantiation when criteria cannot be satisfied.
*/
private function createEligibilityCriteria(ParticipantDto $participant, BookingCreateDto $booking): ?InsuranceEligibilityCriteria
private function createEligibilityCriteria(ParticipantDto $participant, BookingDto $booking): ?InsuranceEligibilityCriteria
{
// Early return if travel dates are missing - cannot evaluate any criteria
$travelStartDate = $booking->travel->dateFrom;
@@ -183,8 +185,13 @@ class InsuranceMatchingService
* Family insurances should only be available for family bookings,
* and individual insurances should only be available for non-family bookings.
*/
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingCreateDto $booking): bool
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool
{
// Family booking detection only available in BookingCreateDto
if (!$booking instanceof BookingCreateDto) {
return true; // Skip family constraints for edit mode
}
$isFamilyBooking = $booking->isFamilyBooking();
// If it's a family insurance, it should only be available for family bookings
@@ -308,12 +315,12 @@ class InsuranceMatchingService
* It excludes insurance prices to prevent circular dependency where insurance selection
* affects travel price which then affects insurance eligibility.
*
* @param BookingCreateDto $booking The booking to calculate price for
* @param BookingDto $booking The booking to calculate price for
* @param int $participantIndex The participant index to calculate for
*
* @return float The total travel price for the participant excluding insurance
*/
private function calculateTravelPrice(BookingCreateDto $booking, int $participantIndex): float
private function calculateTravelPrice(BookingDto $booking, int $participantIndex): float
{
// Use the price calculator to get the participant's individual price excluding insurance
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
@@ -6,7 +6,7 @@ namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use Carbon\CarbonImmutable;
use Spatie\Blink\Blink;
@@ -31,12 +31,12 @@ class ParticipantEligibilityService
*
* Results are cached per request to avoid redundant calculations.
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
*
* @return bool True if participant is eligible (has available skipasses)
*/
public function isParticipantEligible(BookingDtoInterface $bookingDto, int $participantIndex): bool
public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
@@ -66,7 +66,7 @@ class ParticipantEligibilityService
/**
* Checks if a skipass service is available for the given participant based on age constraints.
*/
private function isSkiPassAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool
private function isSkiPassAvailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
+76 -28
View File
@@ -121,7 +121,7 @@ class TravelDataService
'error' => $e->getMessage(),
]);
throw $e;
} catch (HotelNotFoundException | HotelNotInTravelException $e) {
} catch (HotelNotFoundException|HotelNotInTravelException $e) {
$this->logger->debug('Hotel not found in XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
@@ -433,7 +433,44 @@ class TravelDataService
*
* @return BaseData|null The mutability data or null if not available or error occurred
*/
public function getMutabilityData(int $dateId): ?BaseData
/**
* Gets mutability data for a travel date.
*
* @param int $dateId The travel date ID for API call
* @param bool $cached Whether to use cached data (default: true, TTL: 12 hours)
*
* @return BaseData|null The mutability data or null if not available or error occurred
*/
public function getMutabilityData(int $dateId, bool $cached = true): ?BaseData
{
if ($cached) {
$cacheKey = sprintf('mutability_%d', $dateId);
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) {
// 12 hours TTL - mutability dates have date-only granularity
$item->expiresAfter(43200);
return $this->fetchMutabilityData($dateId);
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error in getMutabilityData', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
// Fallback to direct API call
return $this->fetchMutabilityData($dateId);
}
}
return $this->fetchMutabilityData($dateId);
}
/**
* Fetches mutability data directly from the API without caching.
*/
private function fetchMutabilityData(int $dateId): ?BaseData
{
try {
$mutableData = $this->apiClient->getMutableData($dateId);
@@ -482,16 +519,44 @@ class TravelDataService
}
/**
* Fetch availability data from API.
*
* Retrieves availability information from the API for a specific travel date.
* Handles API errors and notification responses gracefully.
* Gets availability data for a travel date.
*
* @param int $dateId The travel date ID for API call
* @param bool $cached Whether to use cached data (default: false, TTL when cached: 60 seconds)
* @param int $ttl Cache TTL in seconds when $cached is true (default: 60 seconds)
*
* @return BaseData|null The availability data or null if not available or error occurred
*/
public function getAvailabilityData(int $dateId): ?BaseData
public function getAvailabilityData(int $dateId, bool $cached = false, int $ttl = 60): ?BaseData
{
if ($cached) {
$cacheKey = sprintf('availability_%d', $dateId);
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $ttl) {
// Short TTL - availability is volatile and changes with bookings
$item->expiresAfter($ttl);
return $this->fetchAvailabilityData($dateId);
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error in getAvailabilityData', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
// Fallback to direct API call
return $this->fetchAvailabilityData($dateId);
}
}
return $this->fetchAvailabilityData($dateId);
}
/**
* Fetches availability data directly from the API without caching.
*/
private function fetchAvailabilityData(int $dateId): ?BaseData
{
try {
$availabilities = $this->apiClient->getAvailabilities($dateId);
@@ -524,9 +589,7 @@ class TravelDataService
/**
* Fetch availability data with short-term caching.
*
* Retrieves availability information from the API with caching to reduce
* API calls during booking form interactions. Uses a short TTL to ensure
* reasonably fresh data while avoiding excessive API requests.
* @deprecated Use getAvailabilityData($dateId, cached: true) instead
*
* @param int $dateId The travel date ID for API call
* @param int $ttl Cache TTL in seconds (default: 60 seconds)
@@ -535,23 +598,7 @@ class TravelDataService
*/
public function getAvailabilityDataCached(int $dateId, int $ttl = 60): ?BaseData
{
$cacheKey = sprintf('availability_%d', $dateId);
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $ttl) {
$item->expiresAfter($ttl);
return $this->getAvailabilityData($dateId);
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error in getAvailabilityDataCached', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
// Fallback to direct API call
return $this->getAvailabilityData($dateId);
}
return $this->getAvailabilityData($dateId, cached: true, ttl: $ttl);
}
/**
@@ -606,7 +653,8 @@ class TravelDataService
{
try {
$insurances = $this->insuranceLoader->loadAll();
$travel->insurances = array_values($insurances);
// Keep insurances indexed by ID for efficient lookups
$travel->insurances = $insurances;
// Hydrate package relationships after loading
// Packages lose their containedInsurances during serialization, so rebuild them
+2 -2
View File
@@ -3,7 +3,7 @@
namespace App\Twig;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\BookingDto;
use App\Service\ParticipantEligibilityService;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
@@ -97,7 +97,7 @@ class AppRuntime implements RuntimeExtensionInterface
return $this->countryDataProvider->get($nationality)?->nationality;
}
public function isParticipantEligible(BookingDtoInterface $bookingDto, int $participantIndex): bool
public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool
{
return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex);
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ApplicantAddress extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ApplicantAddressValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
/** @var ParticipantDto $participant */
$participant = $value;
// Only validate for the first participant (applicant in create mode)
if (false === $participant->isApplicant()) {
return;
}
// Ensure address object exists
if (null === $participant->address) {
return;
}
// Validate required address fields
if (null === $participant->address->street || '' === trim($participant->address->street)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.street')
->addViolation()
;
}
if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.postCode')
->addViolation()
;
}
if (null === $participant->address->city || '' === trim($participant->address->city)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.city')
->addViolation()
;
}
if (null === $participant->address->country || '' === trim($participant->address->country)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.country')
->addViolation()
;
}
// Mobile/phone is mandatory for applicant in create mode
if (null === $participant->mobile || '' === trim($participant->mobile)) {
$this->context->buildViolation('Bitte angeben')
->atPath('mobile')
->addViolation()
;
}
}
}
@@ -16,7 +16,6 @@ class ParticipantValidator extends ConstraintValidator
$this->assertBodyMeasurementsValid($participant);
$this->assertTransportationSelected($participant);
$this->assertPickupSelected($participant);
$this->assertApplicantAddressValid($participant);
}
public function assertBodyMeasurementsValid(ParticipantDto $participant): void
@@ -68,49 +67,4 @@ class ParticipantValidator extends ConstraintValidator
;
}
}
public function assertApplicantAddressValid(ParticipantDto $participant): void
{
// Address is only mandatory for the applicant (first participant)
if (false === $participant->isApplicant()) {
return;
}
// Check each required address field for applicant
if (null === $participant->address->street || '' === trim($participant->address->street)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.street')
->addViolation()
;
}
if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.postCode')
->addViolation()
;
}
if (null === $participant->address->city || '' === trim($participant->address->city)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.city')
->addViolation()
;
}
if (null === $participant->address->country || '' === trim($participant->address->country)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.country')
->addViolation()
;
}
// Mobile/phone is mandatory for applicant
if (null === $participant->mobile || '' === trim($participant->mobile)) {
$this->context->buildViolation('Bitte angeben')
->atPath('mobile')
->addViolation()
;
}
}
}
+42
View File
@@ -1,6 +1,48 @@
<div class="booking-summary p-8 bg-gray-50 rounded-lg border sticky top-4">
<h3 class="font-bold text-xl mb-6 text-gray-800">Buchungsübersicht</h3>
{# Mutability information (edit mode only) #}
{% if mutableData is defined %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Aktualisierung möglich bis</h4>
<ul class="space-y-1 text-sm text-gray-600">
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Zusatzleistungen:</span>
{% if mutableData.items['additional_services'].mutable %}
{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Beförderung:</span>
{% if mutableData.items['transportation'].mutable %}
{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Zustiege:</span>
{% if mutableData.items['pickup'].mutable %}
{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
</ul>
</div>
{% endif %}
{# Travel Information #}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Reiseinformationen</h4>
+7 -5
View File
@@ -71,7 +71,7 @@
<fieldset>
<legend class="w-full flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-bold text-xl">Teilnehmer:in {{ loop.index }}</span>
<span class="font-bold text-xl">{{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }}</span>
{% if isEligible and participantPrices is defined and participantPrices[loop.index0] is defined and participantPrices[loop.index0] > 0.0 %}
<span class="text-sm font-medium text-gray-600 bg-gray-100 px-2 py-1 rounded">
{{ participantPrices[loop.index0]|number_format(2, ',', '.') }}
@@ -213,10 +213,12 @@
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{% if participantData.insurance %}
{{ participantData.insurance.label }}
{% if participantData.insurance.price and participantData.insurance.price > 0 %}
<span class="text-gray-500">(€{{ participantData.insurance.price|number_format(2, ',', '.') }})</span>
{% set applicantInsurance = form.vars.data.participants[0].insurance %}
{% if applicantInsurance %}
{{ applicantInsurance.label }}
{% set participantPrice = participantPrices[loop.index0] %}
{% if participantPrice.insurance and participantPrice.insurance > 0 %}
<span class="text-gray-500">(€{{ participantPrice.insurance|number_format(2, ',', '.') }})</span>
{% endif %}
<span class="italic text-gray-500 ml-2"> wie Anmelder</span>
{% else %}
+74 -101
View File
@@ -53,104 +53,7 @@
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
<h1 class="text-3xl font-semibold pb-2">Buchung bearbeiten</h1>
{# Booking metadata section #}
<div class="flex flex-col space-y-8 pb-8">
<div class="p-8 border border-gray-300 rounded-md"
{{ stimulus_controller('toggle', { 'open': true }, { 'closed': 'hidden' }) }}>
<div role="button" tabindex="0" class="flex items-center justify-between focus:outline-2 focus:outline-offset-2 focus:outline-primary-light" {{ stimulus_action('toggle', 'toggle', 'click') | stimulus_action('toggle', 'toggle', 'keydown.space') }}>
<span class="block text-2xl font-semibold">Reisedaten</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6" {{ stimulus_target('toggle', 'icon') }}>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-3 gap-x-8 gap-y-4 pt-4" {{ stimulus_target('toggle', 'toggle') }}>
<div>
<h2 class="text-lg font-semibold">
Reise
</h2>
{{ bookingData.travelName }}
</div>
<div>
<h3 class="text-lg font-semibold">
Reisezeitraum
</h3>
{{ travelData.dateFrom|date('d.m.Y') }} - {{ travelData.dateTo|date('d.m.Y') }}
</div>
<div>
<h3 class="text-lg font-semibold">
Buchungsdatum
</h3>
{{ bookingData.bookingDate|date('d.m.Y') }}
</div>
<div>
<h3 class="text-lg font-semibold">
Vorgangsnummer
</h3>
{{ bookingData.bookingNumber }}
</div>
<div>
<h3 class="text-lg font-semibold">
Rechnungsnummer
</h3>
{{ bookingData.invoiceNumber }}
</div>
<div>
<h3 class="text-lg font-semibold">
Anmelder:in
</h3>
{{ bookingData.applicant.firstName }} {{ bookingData.applicant.name }}
</div>
<div>
<h3 class="text-lg font-semibold">
Anzahl Teilnehmer:innen
</h3>
{{ bookingData.participants|length }}
</div>
<div>
<h3 class="text-lg font-semibold">
Gesamtpreis
</h3>
{{ bookingData.calculatedTotalPrice|format_currency('EUR') }}
</div>
<div>
<h3 class="text-lg font-semibold">
Inklusivleistungen
</h3>
{% if travelData.includedServices | length == 0 %}
-
{% else %}
<ul class="list-disc pl-4">
{% for selectionGroup in travelData.includedServices %}
{% for service in selectionGroup.selections %}
<li>
{{ service.label }}
</li>
{% endfor %}
{% endfor %}
</ul>
{% endif %}
</div>
<div>
<h3 class="text-lg font-semibold">
Aktualisierung möglich bis
</h3>
<ul class="list-disc pl-4">
<li>
Zusatzleistungen: {% if mutableData.items['additional_services'].mutable %}{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %}
</li>
<li>
Beförderung: {% if mutableData.items['transportation'].mutable %}{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %}
</li>
<li>
Zustiege: {% if mutableData.items['pickup'].mutable %}{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %}
</li>
</ul>
</div>
</div>
</div>
</div>
<h1 class="text-3xl font-semibold pb-8">Buchung bearbeiten</h1>
{# Grid layout with 2/3 form + 1/3 summary #}
<div class="grid grid-cols-3 gap-8">
@@ -172,7 +75,7 @@
<fieldset>
<legend class="w-full flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-bold text-xl">Teilnehmer:in {{ loop.index }}</span>
<span class="font-bold text-xl">{{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }}</span>
{% if isCanceled %}
<span class="inline-block px-2 py-1 text-xs bg-red-700 text-white rounded">storniert</span>
{% endif %}
@@ -207,7 +110,7 @@
</div>
{% endif %}
{% else %}
{# Personal data section #}
{# Personal data section - readonly for applicant (edited via profile settings) #}
<div class="grid grid-cols-2 gap-4 pb-4">
{{ form_row(participant.firstName) }}
{{ form_row(participant.lastName) }}
@@ -223,8 +126,29 @@
</div>
<div class="grid grid-cols-2 gap-4">
{{ form_row(participant.email) }}
{% if loop.index0 == 0 and bookingData.applicant.communication and bookingData.applicant.communication.mobile %}
{# First participant: use applicant's mobile as placeholder #}
{{ form_row(participant.mobile, {'attr': {'placeholder': bookingData.applicant.communication.mobile}}) }}
{% else %}
{{ form_row(participant.mobile) }}
{% endif %}
</div>
{% if participant.address is defined %}
<div class="grid grid-cols-2 gap-4">
{% if loop.index0 == 0 and bookingData.applicant.address %}
{# First participant: use applicant's address as placeholder #}
{{ form_row(participant.address.street, {'attr': {'placeholder': bookingData.applicant.address.street}}) }}
{{ form_row(participant.address.postCode, {'attr': {'placeholder': bookingData.applicant.address.postCode}}) }}
{{ form_row(participant.address.city, {'attr': {'placeholder': bookingData.applicant.address.city}}) }}
{{ form_row(participant.address.country, {'attr': {'placeholder': bookingData.applicant.address.country}}) }}
{% else %}
{{ form_row(participant.address.street) }}
{{ form_row(participant.address.postCode) }}
{{ form_row(participant.address.city) }}
{{ form_row(participant.address.country) }}
{% endif %}
</div>
{% endif %}
{% if participant.bodyDimensions is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(participant.bodyDimensions.height) }}
@@ -317,6 +241,54 @@
}
}) }}
<div class="col-span-2">
{# Insurance field OR assigned insurance display for dependent participants #}
{% set participantData = participant.vars.data %}
{% set showBulkInsurance = loop.index > 1 and form.vars.data.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{% set applicantInsurance = form.vars.data.participants[0].insurance %}
{% if applicantInsurance %}
<span class="italic text-gray-500">wie Anmelder: {{ applicantInsurance.label }}</span>
{% else %}
<span class="italic text-gray-500">wie Anmelder</span>
{% endif %}
</div>
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
{# Bulk insurance booking checkbox (applicant only) #}
{% if participant.bulkInsuranceBooking is defined %}
{{ form_row(participant.bulkInsuranceBooking, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.insurance is defined %}
{{ form_row(participant.insurance, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
},
'label': false
}) }}
{% else %}
<div class="text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% endif %}
</div>
<div class="col-span-2">
<h3 class="text-xl font-semibold mb-4">Hin-/Rückreise</h3>
<div class="grid grid-cols-2 gap-4">
@@ -401,7 +373,8 @@
'bookingCreateDto': bookingEditDto,
'participantCount': participantCount,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
'assignmentCounts': assignmentCounts,
'mutableData': mutableData|default(null)
} %}
</div>
{% endblock %}