Compare commits
5
Commits
3177451a9c
...
e35a7ab881
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35a7ab881 | ||
|
|
00eaea57f6 | ||
|
|
fafc60eba9 | ||
|
|
bd2d8b58a3 | ||
|
|
52327864d6 |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ const {defineConfig} = require("cypress");
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'https://myep-next-booking.ddev.site',
|
||||
baseUrl: 'https://myep.ddev.site',
|
||||
supportFile: false,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
# Body Dimensions Transition Plan (Stakeholder Draft)
|
||||
|
||||
> **STATUS (verified 2026-07-15): NOT IMPLEMENTED.** `src/Form/BodyDimensionsType.php` still declares `height`, `shoeSize`, and `weight` as `IntegerType` fields, and `src/Validator/Constraints/ParticipantValidator.php` has no regex/legacy-format handling — only numeric range checks. This plan has not been acted on.
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Booking Flow Reference
|
||||
|
||||
Last updated: 2026-04-16
|
||||
Last updated: 2026-07-15
|
||||
|
||||
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
|
||||
|
||||
@@ -35,7 +35,7 @@ Step 1 (room selection)
|
||||
|
||||
### Entry / Bootstrap
|
||||
|
||||
`BookingExceptionHandlerTrait::getOrCreateBookingCreateDto()` is called by every step controller. If no DTO exists in session, it calls `BookingConfigurator::startFreshBooking()` which loads travel data and initialises a fresh `BookingDto`.
|
||||
`AbstractBookingCreateController::loadBookingCreateDto()` is called by every step controller and delegates to `BookingSessionManager::getOrCreateBookingCreateDto()`. If no DTO exists in session, `BookingConfigurator::startFreshBooking()` loads travel data and initialises a fresh `BookingDto`.
|
||||
|
||||
### Step 1 — Room Selection
|
||||
|
||||
@@ -62,7 +62,7 @@ HTMX refresh (`/bookings/create/refresh`): processes the form without validation
|
||||
1. Load DTO from session (or redirect to Step 1 if missing)
|
||||
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
|
||||
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
|
||||
4. `ParticipantDataPrefiller::shouldPrepopulateApplicant()` + `prepopulateApplicantFromUser()` — prefills participant[0] from user profile on first visit
|
||||
4. `ParticipantDataPrefiller::shouldPrefillApplicant()` + `prefillApplicantFromUser()` — prefills participant[0] from user profile on first visit
|
||||
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
|
||||
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
|
||||
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
|
||||
|
||||
@@ -125,8 +125,6 @@ Delete Draft on Success
|
||||
|
||||
```
|
||||
src/
|
||||
├── Admin/ # EasyAdmin custom fields
|
||||
│ └── Field/JsonDataField.php
|
||||
├── BusProNet/ # BusProNet API integration layer
|
||||
│ ├── ApiClient.php # Main API client
|
||||
│ ├── Constants.php # Service tokens and constants
|
||||
@@ -134,12 +132,12 @@ src/
|
||||
│ ├── DataProvider/ # Data providers (countries)
|
||||
│ ├── Exception/ # API-specific exceptions
|
||||
│ ├── Form/ # Form choice loaders
|
||||
│ ├── Model/ # Data models (39 classes)
|
||||
│ ├── Model/ # Data models (41 classes)
|
||||
│ ├── Traits/ # Shared traits
|
||||
│ ├── Utility/ # Helper utilities
|
||||
│ ├── XmlLoader/ # Cached XML file loaders
|
||||
│ └── XmlParser/ # XML response parsers (28 classes)
|
||||
├── Command/ # CLI commands (9 commands)
|
||||
│ └── XmlParser/ # XML response parsers (29 classes)
|
||||
├── Command/ # CLI commands (15 commands)
|
||||
├── Controller/ # HTTP controllers
|
||||
│ ├── Account/ # Account management
|
||||
│ ├── Admin/ # EasyAdmin controllers
|
||||
@@ -151,6 +149,7 @@ src/
|
||||
│ ├── RegistrationController.php
|
||||
│ ├── ResetPasswordController.php
|
||||
│ └── SecurityController.php
|
||||
├── Email/ # Transactional email builders
|
||||
├── Entity/ # Doctrine entities
|
||||
│ ├── User.php
|
||||
│ ├── LogEntry.php
|
||||
@@ -160,16 +159,19 @@ src/
|
||||
├── Form/ # Form system
|
||||
│ ├── Model/ # DTOs (BookingDto, ParticipantDto, etc.)
|
||||
│ ├── Service/ # Field handlers & conditions
|
||||
│ │ └── Condition/ # 22+ condition classes
|
||||
│ │ └── Condition/ # 25 condition classes
|
||||
│ ├── DataTransformer/ # Form data transformers
|
||||
│ └── Extension/ # Form extensions (XSS protection)
|
||||
├── Htmx/ # HTMX utilities
|
||||
├── Logger/ # Custom Monolog handlers/processors
|
||||
├── Menu/ # Navigation menu builders
|
||||
├── Message/ # Messenger message classes
|
||||
├── MessageHandler/ # Messenger message handlers
|
||||
├── Model/ # Application models
|
||||
├── Repository/ # Doctrine repositories
|
||||
├── Security/ # Authentication & authorization
|
||||
│ └── Voter/ # Custom voters
|
||||
├── Service/ # Business logic services (21 services)
|
||||
├── Service/ # Business logic services (43 services)
|
||||
├── Twig/ # Template extensions
|
||||
└── Validator/ # Custom validation constraints
|
||||
└── Constraints/ # Constraint classes
|
||||
@@ -196,7 +198,7 @@ assets/
|
||||
├── app.js # Main entry point
|
||||
├── bootstrap.js # Stimulus initialization
|
||||
├── loading.js # HTMX loading indicator
|
||||
├── controllers/ # Stimulus controllers (10)
|
||||
├── controllers/ # Stimulus controllers (14)
|
||||
├── styles/ # TailwindCSS styles
|
||||
│ ├── app.css # Main stylesheet
|
||||
│ └── components/ # Component styles
|
||||
@@ -1001,6 +1003,10 @@ public function deleteExpiredDrafts(): int;
|
||||
| `toast_controller` | Toast notifications via Toastify |
|
||||
| `tooltip_controller` | Tooltips via Tippy.js |
|
||||
| `backbutton_controller` | Browser back navigation |
|
||||
| `data_layer_controller` | Pushes analytics events to the data layer |
|
||||
| `mobilenav_controller` | Mobile navigation menu |
|
||||
| `password_reveal_controller` | Show/hide password input |
|
||||
| `sidebar_menu_controller` | Sidebar menu state |
|
||||
|
||||
### 9.2 HTMX Integration
|
||||
|
||||
@@ -1121,6 +1127,22 @@ php bin/console app:bpn:xml-sync [--force|-f] [--dry-run]
|
||||
|
||||
Synchronizes XML export files from remote SFTP. Invalidates caches on completion.
|
||||
|
||||
#### app:bpn:refresh-travel-snapshot
|
||||
|
||||
```bash
|
||||
php bin/console app:bpn:refresh-travel-snapshot
|
||||
```
|
||||
|
||||
Refreshes active travel snapshots using extended availability data.
|
||||
|
||||
#### app:bpn:xml-cache-invalidate
|
||||
|
||||
```bash
|
||||
php bin/console app:bpn:xml-cache-invalidate
|
||||
```
|
||||
|
||||
Invalidates BPN XML caches when local `uebertragung.info` changes.
|
||||
|
||||
### 10.2 Maintenance Commands
|
||||
|
||||
#### app:draft:cleanup
|
||||
@@ -1185,6 +1207,22 @@ Creates or deletes the Mailjet `unsub` callback for `POST /webhooks/mailjet/news
|
||||
- `register` prints the API response payload so you can note the callback ID.
|
||||
- `remove` and `deactivate` require the callback ID returned by Mailjet.
|
||||
|
||||
#### app:db:anonymize
|
||||
|
||||
```bash
|
||||
php bin/console app:db:anonymize
|
||||
```
|
||||
|
||||
Anonymizes personal data in the local database.
|
||||
|
||||
#### app:log-entry:backfill-error-codes
|
||||
|
||||
```bash
|
||||
php bin/console app:log-entry:backfill-error-codes
|
||||
```
|
||||
|
||||
Backfills the `error_code` column from extra data in existing log entries.
|
||||
|
||||
### 10.3 Setup Commands
|
||||
|
||||
#### app:crypto:generate-keys
|
||||
@@ -1288,7 +1326,7 @@ This section documents known API behaviors and the solutions implemented.
|
||||
|
||||
**Solution:** Automatic retry with configurable attempts (default: 3) and delay (default: 1s).
|
||||
|
||||
**Location:** `src/BusProNet/ApiClient.php:625-659`
|
||||
**Location:** `src/BusProNet/ApiClient.php:646` (`executeWithRetry`)
|
||||
|
||||
```php
|
||||
private function executeWithRetry(callable $operation, ?string $type = null): mixed
|
||||
@@ -1347,7 +1385,7 @@ public function shouldProcess(...): bool
|
||||
|
||||
**Solution:** Exclude `personId` and `addressId` from create mode payload.
|
||||
|
||||
**Location:** `src/BusProNet/DataProcessor/BookingPayloadBuilder.php:247-249`
|
||||
**Location:** `src/BusProNet/DataProcessor/BookingPayloadBuilder.php:275,313`
|
||||
|
||||
### 13.6 Personal Data Mutability Flag
|
||||
|
||||
@@ -1365,7 +1403,7 @@ public function shouldProcess(...): bool
|
||||
|
||||
**Solution:** Inject synthetic option with ID '0' that satisfies validation but is excluded from API transmission.
|
||||
|
||||
**Location:** `src/BusProNet/Model/Insurance.php:19-26`
|
||||
**Location:** `src/BusProNet/Model/Insurance.php:26` (constant), `:201` (`isNoInsurance()`)
|
||||
|
||||
```php
|
||||
public const NO_INSURANCE_ID = '0';
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
@@ -301,8 +302,8 @@ class BookingDataProcessor
|
||||
*/
|
||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||
{
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($formData);
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($formData);
|
||||
|
||||
$bookingData = $formData->booking;
|
||||
$travelData = $formData->travel;
|
||||
@@ -362,33 +363,131 @@ class BookingDataProcessor
|
||||
*/
|
||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($bookingDto);
|
||||
// Re-validate each participant's insurance against current eligibility (price tier,
|
||||
// family constraint, etc.) before submission - selections can go stale between the
|
||||
// moment they were chosen and final submission, since users can freely revisit
|
||||
// earlier steps and change composition/services without ever re-opening the
|
||||
// insurance field. Must run before the applicant-to-dependents propagation below,
|
||||
// so that propagation is based on an already-corrected applicant selection.
|
||||
$this->reconcileInsuranceEligibility($bookingDto);
|
||||
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
|
||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies bulk insurance assignment if the applicant has enabled it.
|
||||
* Re-validates every participant's currently selected insurance against present-day
|
||||
* eligibility (price tier, family constraint, age, travel/booking dates, duration) and
|
||||
* corrects any selection that's gone stale.
|
||||
*
|
||||
* 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.
|
||||
* Insurance eligibility is normally re-checked when a participant's own card is
|
||||
* resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces
|
||||
* that to happen again if the user changes composition or services on an earlier step
|
||||
* after insurance was already chosen, then jumps straight to submission. Left unchecked,
|
||||
* a stale insurance ID (e.g. a family-tariff price tier that no longer matches the
|
||||
* current total booking price) would reach the BPN API and be rejected as a mismatch.
|
||||
*
|
||||
* IMPORTANT: In edit mode, bulk insurance only applies to participants who:
|
||||
* Mirrors the reassign-or-clear logic already established in
|
||||
* ParticipantInsuranceFieldHandler for the resubmission case, applied here across all
|
||||
* participants right before the payload is built.
|
||||
*/
|
||||
private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool
|
||||
{
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$changed = false;
|
||||
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$currentInsurance = $participant->insurance;
|
||||
if (null === $currentInsurance || $currentInsurance->isNoInsurance()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance);
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
|
||||
if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
|
||||
$selectableInsurances,
|
||||
$currentInsurance,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
if (null !== $reassignedInsurance) {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label)
|
||||
);
|
||||
} else {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.'
|
||||
);
|
||||
}
|
||||
|
||||
$participant->insurance = $reassignedInsurance;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific insurance exists in a list of insurances (by id).
|
||||
*
|
||||
* @param array<Insurance> $insuranceList
|
||||
*/
|
||||
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
|
||||
{
|
||||
foreach ($insuranceList as $insurance) {
|
||||
if ($insurance->id === $targetInsurance->id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the applicant's insurance to other participants if their selection requires it.
|
||||
*
|
||||
* Runs when either:
|
||||
* - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the
|
||||
* applicant's insurance TYPE to all dependent participants with automatic price tier
|
||||
* adjustment based on each participant's individual cost.
|
||||
* - The applicant selected a family insurance: family insurance covers the whole family
|
||||
* under a single policy, so dependents must never hold their own insurance, regardless
|
||||
* of whether the bulk checkbox was checked.
|
||||
*
|
||||
* IMPORTANT: In edit mode, non-family 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.
|
||||
* This prevents overriding individually selected insurances that are locked. Family
|
||||
* insurance is exempt from this protection: dependents can never legitimately hold
|
||||
* their own policy alongside it, so their insurance is always cleared, in edit mode too.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
|
||||
*/
|
||||
private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void
|
||||
private function applyApplicantInsuranceToParticipants(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) {
|
||||
if (null === $applicant || null === $applicant->insurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isFamilyInsurance = true === $applicant->insurance->familyInsurance;
|
||||
|
||||
// Check if bulk insurance is enabled OR the applicant's selection is a family
|
||||
// insurance (which always applies to dependents, independent of the bulk checkbox)
|
||||
if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,10 +508,11 @@ class BookingDataProcessor
|
||||
$participantPrices
|
||||
);
|
||||
|
||||
// Apply assignments to dependent participants (skip applicant at index 0)
|
||||
// Apply assignments to all participants; for non-family insurance skip the applicant
|
||||
// (they already hold the insurance they selected themselves)
|
||||
foreach ($assignments as $index => $insurance) {
|
||||
if (0 === $index) {
|
||||
continue; // Skip applicant
|
||||
if (0 === $index && false === $isFamilyInsurance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($index);
|
||||
@@ -420,9 +520,10 @@ class BookingDataProcessor
|
||||
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::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) {
|
||||
// In edit mode: non-family bulk assignment must not overwrite an individually
|
||||
// locked insurance. Family insurance always overrides, since dependents can
|
||||
// never legitimately keep their own policy alongside it.
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) {
|
||||
continue; // Skip participants with existing insurance assignment
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\BookingPriceMismatchAnalyzer;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use App\Service\RoomPricingCalculator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -40,6 +41,7 @@ class Step3Controller extends AbstractBookingCreateController
|
||||
private readonly BookingPriceCalculator $priceCalculator,
|
||||
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -76,6 +78,12 @@ class Step3Controller extends AbstractBookingCreateController
|
||||
// Validate booking data with API by submitting an inquiry booking
|
||||
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
|
||||
|
||||
// Surface any insurance corrections made during payload building (e.g. a
|
||||
// stale price tier reassigned/cleared right before submission)
|
||||
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
|
||||
if ($inquiryResponse instanceof Notification) {
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\NewsletterManager;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -41,6 +42,7 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly NewsletterManager $doubleOptInService,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -78,6 +80,12 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
// Submit final booking (already validated in Step 3)
|
||||
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
|
||||
|
||||
// Surface any insurance corrections made during payload building (e.g. a
|
||||
// stale price tier reassigned/cleared right before submission)
|
||||
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
|
||||
if ($bookingResponse instanceof Notification) {
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
|
||||
@@ -70,6 +70,21 @@ class BookingDto
|
||||
*/
|
||||
public ?string $originalFingerprint = null;
|
||||
|
||||
/**
|
||||
* Whether the applicant has already been notified that family insurance became
|
||||
* available after they picked a non-family insurance while dependents were incomplete.
|
||||
* Prevents re-showing the same hint on every subsequent participant card submission.
|
||||
*/
|
||||
public bool $familyInsuranceHintShown = false;
|
||||
|
||||
/**
|
||||
* Whether the applicant's current insurance choice was made while family insurance
|
||||
* was not yet eligible (i.e. before all dependents' dates of birth were known).
|
||||
* Set at selection time; used to distinguish "family insurance just became
|
||||
* available" from "it was available all along and the applicant chose otherwise".
|
||||
*/
|
||||
public bool $applicantInsuranceChosenWhileFamilyIneligible = false;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -67,9 +67,9 @@ class ParticipantEditDto
|
||||
* Validates that insurance is selected when required.
|
||||
*
|
||||
* Insurance is required for all participants in create mode, EXCEPT for dependent
|
||||
* participants when the applicant has enabled bulk insurance booking. In that case,
|
||||
* the insurance field is hidden and will be automatically assigned by the bulk
|
||||
* insurance handler.
|
||||
* participants when the applicant has enabled bulk insurance booking, or when the
|
||||
* applicant has a family insurance selected. In both cases, the insurance field is
|
||||
* hidden and coverage is derived from the applicant instead.
|
||||
*
|
||||
* In edit submissions, this validation is skipped entirely because insurance
|
||||
* data is readonly and preserved as-is from the BPN API.
|
||||
@@ -90,6 +90,10 @@ class ParticipantEditDto
|
||||
if (true === $applicant?->bulkInsuranceBooking) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $applicant?->insurance && true === $applicant->insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $this->participant->insurance) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* True for dependent participants when the applicant has a family insurance selected.
|
||||
*
|
||||
* Family insurance covers the whole family under a single policy on the applicant, so
|
||||
* dependents must not be offered their own insurance selection while it's active.
|
||||
*/
|
||||
class FamilyInsuranceActiveCondition implements FieldConditionInterface
|
||||
{
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
if (0 === $participantIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
return null !== $applicant
|
||||
&& null !== $applicant->insurance
|
||||
&& true === $applicant->insurance->familyInsurance;
|
||||
}
|
||||
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return ['insurance'];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Applicant has a family insurance selected';
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Form\Service\Condition\BookingEligibilityCondition;
|
||||
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
|
||||
use App\Form\Service\Condition\CompositeCondition;
|
||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
||||
@@ -221,11 +222,13 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
),
|
||||
];
|
||||
|
||||
// Hide insurance field until date of birth is provided OR when bulk insurance booking is active OR when participant is ineligible
|
||||
// Hide insurance field until date of birth is provided OR when bulk insurance booking is active
|
||||
// OR when the applicant has a family insurance active (it already covers dependents) OR when participant is ineligible
|
||||
$this->fieldStateConditions['insurance'] = [
|
||||
'hidden' => CompositeCondition::or(
|
||||
CompositeCondition::not($dateOfBirthProvidedCondition),
|
||||
$bulkInsuranceBookingCondition,
|
||||
new FamilyInsuranceActiveCondition(),
|
||||
$bookingEligibilityCondition
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1144,15 +1144,17 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
// Get selectable (non-complementary) insurances with caching
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
|
||||
// Calculate travel price for eligibility filtering
|
||||
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
// Family insurances are priced by the total booking price rather than the individual
|
||||
// participant's price - InsuranceManager applies the correct basis per insurance type
|
||||
$individualPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
$totalBookingPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto);
|
||||
|
||||
// Filter based on eligibility criteria for this participant
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurancesForParticipant(
|
||||
$selectableInsurances,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
$individualPrice,
|
||||
$totalBookingPrice
|
||||
);
|
||||
|
||||
// Inject synthetic "no insurance" option at the top of the list
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
@@ -124,6 +125,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
$this->applyInsuranceSelection($submittedData, $bookingDto, $participantIndex);
|
||||
|
||||
// Runs after the selection above is fully resolved, so a submission that itself
|
||||
// switches the applicant to family insurance doesn't trigger a stale "please
|
||||
// recheck" notification about the choice it just made.
|
||||
$this->notifyApplicantIfFamilyInsuranceNewlyAvailable($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
*/
|
||||
private function applyInsuranceSelection(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
@@ -136,9 +150,6 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
// Get selectable (non-complementary) insurances with caching
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
|
||||
// Calculate travel price for eligibility checks
|
||||
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
|
||||
// Determine if this is a new user selection or just form resubmission
|
||||
$isNewSelection = null !== $selectedInsuranceId
|
||||
&& (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance));
|
||||
@@ -155,12 +166,16 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
$noInsurance->price = 0.0;
|
||||
$participant->insurance = $noInsurance;
|
||||
|
||||
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance);
|
||||
|
||||
return; // Skip all other processing for "no insurance"
|
||||
}
|
||||
|
||||
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
||||
|
||||
if (null !== $selectedInsurance) {
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $selectedInsurance);
|
||||
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
|
||||
|
||||
@@ -189,15 +204,22 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
// Insurance not found - clear selection
|
||||
$participant->insurance = null;
|
||||
}
|
||||
|
||||
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $participant->insurance);
|
||||
}
|
||||
|
||||
// Handle form resubmission with existing insurance (automatic reassignment check)
|
||||
if (null !== $currentInsurance && null !== $selectedInsuranceId) {
|
||||
// Only applies when the selection didn't just change above - otherwise this would
|
||||
// re-validate the now-stale $currentInsurance and could overwrite the fresh selection
|
||||
// the block above already made in this same request.
|
||||
if (!$isNewSelection && null !== $currentInsurance && null !== $selectedInsuranceId) {
|
||||
// Preserve "no insurance" selection during resubmission
|
||||
if ($currentInsurance->isNoInsurance()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $currentInsurance);
|
||||
|
||||
// Check if current insurance is still eligible with updated participant data
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
|
||||
@@ -259,6 +281,95 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether the applicant's insurance choice was made while family insurance
|
||||
* was NOT yet eligible, so the notification below can tell "just became available"
|
||||
* apart from "was available all along and the applicant chose non-family anyway".
|
||||
*
|
||||
* Only called when the applicant (index 0) makes a genuine new selection - not on
|
||||
* mere resubmission/price-tier reassignment of an already-standing choice.
|
||||
*/
|
||||
private function recordFamilyIneligibilityAtSelectionTime(BookingDto $bookingDto, int $participantIndex, ?Insurance $insurance): void
|
||||
{
|
||||
if (0 !== $participantIndex || null === $insurance || true === $insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->hasEligibleFamilyInsurance($bookingDto, $applicant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a family insurance is actually eligible right now - not just whether
|
||||
* the participant composition qualifies as a family booking, but whether a selectable
|
||||
* family insurance product actually matches the current total booking price.
|
||||
*
|
||||
* Used by both the selection-time recording above and the notification below, so they
|
||||
* can never disagree on what "family insurance is eligible" means.
|
||||
*/
|
||||
private function hasEligibleFamilyInsurance(BookingDto $bookingDto, ParticipantDto $applicant): bool
|
||||
{
|
||||
if (!$bookingDto->isFamilyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$familyInsurances = array_values(array_filter($selectableInsurances, static fn (Insurance $i) => true === $i->familyInsurance));
|
||||
|
||||
if ([] === $familyInsurances) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$totalPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto);
|
||||
|
||||
return [] !== $this->insuranceService->getEligibleInsurances($familyInsurances, $applicant, $bookingDto, $totalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the applicant when family insurance becomes available after the fact.
|
||||
*
|
||||
* Participants are entered one card at a time, in any order, so the applicant is
|
||||
* likely to pick a non-family insurance before any dependent's date of birth makes
|
||||
* the booking eligible for family insurance. Once that happens, nothing prompts the
|
||||
* applicant to revisit their earlier choice. This surfaces a one-time hint via the
|
||||
* existing notification/toast pipeline (App\Form\Model\ParticipantDto::addNotification()),
|
||||
* mirroring the cross-participant side effect already used by
|
||||
* ParticipantAssignedRoomFieldHandler::resolveRoomCapacityConflict() for room conflicts.
|
||||
*
|
||||
* Requires `applicantInsuranceChosenWhileFamilyIneligible` to be true - i.e. the
|
||||
* choice was actually made before family insurance was possible - not just that
|
||||
* family insurance happens to be eligible now (it may have been all along).
|
||||
*/
|
||||
private function notifyApplicantIfFamilyInsuranceNewlyAvailable(BookingDto $bookingDto): void
|
||||
{
|
||||
if ($bookingDto->familyInsuranceHintShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$bookingDto->applicantInsuranceChosenWhileFamilyIneligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->hasEligibleFamilyInsurance($bookingDto, $applicant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant->addNotification(
|
||||
'info',
|
||||
'Für eure Konstellation ist auch eine Familienversicherung verfügbar und kann über die anmeldende Person gebucht werden.'
|
||||
);
|
||||
$bookingDto->familyInsuranceHintShown = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an insurance by ID from the available insurances array.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,22 @@ class BookingPriceCalculator
|
||||
return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total booking price across all participants, excluding insurance.
|
||||
*
|
||||
* Used for family insurance price-tier determination, where the tier is based on
|
||||
* the combined trip cost rather than any single participant's price.
|
||||
*/
|
||||
public function calculateTotalBookingPriceExcludingInsurance(BookingDto $bookingDto): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$total += $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total price for an individual participant excluding insurance.
|
||||
*
|
||||
@@ -95,4 +112,22 @@ class BookingPriceCalculator
|
||||
$participantIndex
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the travel price to use for insurance eligibility checks.
|
||||
*
|
||||
* Family insurances are always evaluated against the total booking price (all
|
||||
* participants combined); everything else uses the individual participant's price.
|
||||
* Non-applicant participants can never hold a family insurance (InsuranceManager
|
||||
* restricts family insurances to the applicant), so this does not need to know
|
||||
* whether $participantIndex is the applicant.
|
||||
*/
|
||||
public function resolveInsuranceTravelPrice(BookingDto $bookingDto, int $participantIndex, Insurance $insurance): float
|
||||
{
|
||||
if (true === $insurance->familyInsurance) {
|
||||
return $this->calculateTotalBookingPriceExcludingInsurance($bookingDto);
|
||||
}
|
||||
|
||||
return $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
}
|
||||
}
|
||||
@@ -202,8 +202,6 @@ class BpnXmlAnonymizer
|
||||
$title = '';
|
||||
$gender = 'D';
|
||||
$nationality = 'XX';
|
||||
$birthDate = '01.01.1980';
|
||||
|
||||
$street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber());
|
||||
$postalCode = $this->faker->postcode();
|
||||
$city = $this->faker->city();
|
||||
@@ -232,7 +230,6 @@ class BpnXmlAnonymizer
|
||||
'title' => $title,
|
||||
'gender' => $gender,
|
||||
'nationality' => $nationality,
|
||||
'birthDate' => $birthDate,
|
||||
'street' => $street,
|
||||
'postalCode' => $postalCode,
|
||||
'city' => $city,
|
||||
@@ -389,11 +386,6 @@ class BpnXmlAnonymizer
|
||||
$this->replaceNodeValue($nationalityNode, $identity['nationality']);
|
||||
}
|
||||
|
||||
$birthDateNode = $this->getDirectChild($personNode, 'geburtsdatum');
|
||||
if (null !== $birthDateNode) {
|
||||
$this->replaceNodeValue($birthDateNode, $identity['birthDate']);
|
||||
}
|
||||
|
||||
$heightNode = $this->getDirectChild($personNode, 'sonstiges1');
|
||||
if (null !== $heightNode) {
|
||||
$this->replaceNodeValue($heightNode, $identity['height']);
|
||||
|
||||
@@ -107,6 +107,42 @@ class InsuranceManager
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns eligible insurances for a participant, pricing family and non-family
|
||||
* insurances on the correct basis.
|
||||
*
|
||||
* Family insurances must be priced by the total booking price, not the individual
|
||||
* participant's price - this splits the given insurances by type and evaluates
|
||||
* each group against the appropriate price before merging the results back together.
|
||||
*
|
||||
* @param array<Insurance> $insurances Available insurances to filter
|
||||
* @param ParticipantDto $participant The participant to match insurances for
|
||||
* @param BookingDto $booking The booking context for additional criteria
|
||||
* @param float $individualPrice The participant's individual travel price (excluding insurance)
|
||||
* @param float $totalBookingPrice The total booking price across all participants (excluding insurance)
|
||||
*
|
||||
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
|
||||
*/
|
||||
public function getEligibleInsurancesForParticipant(
|
||||
array $insurances,
|
||||
ParticipantDto $participant,
|
||||
BookingDto $booking,
|
||||
float $individualPrice,
|
||||
float $totalBookingPrice,
|
||||
): array {
|
||||
$nonFamilyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => false === $i->familyInsurance));
|
||||
$familyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => true === $i->familyInsurance));
|
||||
|
||||
$eligibleInsurances = $this->getEligibleInsurances($nonFamilyInsurances, $participant, $booking, $individualPrice);
|
||||
|
||||
if (!empty($familyInsurances)) {
|
||||
$eligibleFamilyInsurances = $this->getEligibleInsurances($familyInsurances, $participant, $booking, $totalBookingPrice);
|
||||
$eligibleInsurances = $this->sortByPrice(array_merge($eligibleInsurances, $eligibleFamilyInsurances));
|
||||
}
|
||||
|
||||
return $eligibleInsurances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-reassigns an insurance to the same type with appropriate price tier.
|
||||
*
|
||||
@@ -159,12 +195,28 @@ class InsuranceManager
|
||||
BookingDto $booking,
|
||||
array $participantPrices,
|
||||
): array {
|
||||
$assignments = [];
|
||||
|
||||
// Group insurances of the same type
|
||||
$sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance);
|
||||
|
||||
// Assign appropriate insurance to each participant
|
||||
// Family insurance: assign to applicant only, priced by total booking price
|
||||
if (true === $selectedInsurance->familyInsurance) {
|
||||
$totalPrice = array_sum($participantPrices);
|
||||
$applicant = $booking->getParticipant(0);
|
||||
$eligibleInsurances = null !== $applicant
|
||||
? $this->getEligibleInsurances($sameTypeInsurances, $applicant, $booking, $totalPrice)
|
||||
: [];
|
||||
$tieredInsurance = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||
|
||||
$assignments = [];
|
||||
foreach ($booking->getParticipants() as $index => $participant) {
|
||||
$assignments[$index] = 0 === $index ? $tieredInsurance : null;
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
// Non-family insurance: assign to each participant with their individual price tier
|
||||
$assignments = [];
|
||||
foreach ($booking->getParticipants() as $index => $participant) {
|
||||
$travelPrice = $participantPrices[$index] ?? 0.0;
|
||||
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice);
|
||||
@@ -242,7 +294,7 @@ class InsuranceManager
|
||||
int $travelDurationDays,
|
||||
): bool {
|
||||
// Family insurance constraints
|
||||
if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) {
|
||||
if (false === $this->checkFamilyInsuranceConstraints($insurance, $participant, $booking)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -274,23 +326,15 @@ class InsuranceManager
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool
|
||||
private function checkFamilyInsuranceConstraints(Insurance $insurance, ParticipantDto $participant, BookingDto $booking): bool
|
||||
{
|
||||
// Family booking detection only available in create mode
|
||||
if (BookingDto::MODE_EDIT === $booking->getMode()) {
|
||||
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
|
||||
if (true === $insurance->familyInsurance && false === $isFamilyBooking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's not a family insurance, it should only be available for non-family bookings
|
||||
if (false === $insurance->familyInsurance && true === $isFamilyBooking) {
|
||||
return false;
|
||||
if (true === $insurance->familyInsurance) {
|
||||
if (false === $booking->isFamilyBooking()) {
|
||||
return false; // Family insurance only available for family bookings
|
||||
}
|
||||
if (0 !== $participant->index) {
|
||||
return false; // Family insurance only assignable to the applicant
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -28,3 +28,13 @@
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
{% if app.session.flashBag.peek('warning')|length > 0 %}
|
||||
{% embed '_partials/_modal.html.twig' with { 'level': 'warning', 'modal': true } %}
|
||||
{% block title %}
|
||||
Hinweis
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% include '_partials/_alert.html.twig' with { level: 'warning', messages: app.flashes('warning') } %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
|
||||
<div class="absolute inset-0 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
|
||||
<div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-primary-light': level == 'info' }) }}">
|
||||
<div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-yellow-500': level == 'warning', 'bg-primary-light': level == 'info' }) }}">
|
||||
<div class="flex justify-between pb-4 px-4">
|
||||
<div class="text-2xl font-medium text-white">
|
||||
<div class="{{ html_classes('text-2xl font-medium', { 'text-gray-800': level == 'warning', 'text-white': level != 'warning' }) }}">
|
||||
{% block title %}{% endblock %}
|
||||
</div>
|
||||
<button type="button"
|
||||
|
||||
@@ -493,9 +493,15 @@
|
||||
|
||||
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
|
||||
{% else %}
|
||||
{% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %}
|
||||
{# Dependents get the applicant's read-only insurance display instead of their own choice
|
||||
when bulk insurance booking is active, or when the applicant selected a family
|
||||
insurance (which always covers the whole family, regardless of the bulk checkbox) #}
|
||||
{% set showApplicantInsurance = participantIndex > 0 and (
|
||||
bookingDto.participants[0].bulkInsuranceBooking
|
||||
or (bookingDto.participants[0].insurance and bookingDto.participants[0].insurance.familyInsurance)
|
||||
) %}
|
||||
|
||||
{% if showBulkInsurance %}
|
||||
{% if showApplicantInsurance %}
|
||||
{% set applicantInsurance = bookingDto.participants[0].insurance %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\BankAccount;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Room;
|
||||
@@ -822,4 +823,257 @@ class BookingDataProcessorTest extends TestCase
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: family insurance covers the whole family under a single policy on the
|
||||
* applicant, so dependents must never keep their own insurance selection - even when
|
||||
* the applicant never checked the "book for everyone" (bulkInsuranceBooking) box.
|
||||
*/
|
||||
public function testFamilyInsuranceClearsDependentInsuranceWithoutBulkCheckbox(): void
|
||||
{
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->id = 'family-1';
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$applicant = $this->createMockParticipantDto(0, 'F');
|
||||
$applicant->bulkInsuranceBooking = false;
|
||||
$applicant->insurance = $familyInsurance;
|
||||
|
||||
$dependentInsurance = new Insurance();
|
||||
$dependentInsurance->id = 'dependent-own-1';
|
||||
$dependentInsurance->familyInsurance = false;
|
||||
|
||||
$dependent = $this->createMockParticipantDto(1, 'F');
|
||||
$dependent->insurance = $dependentInsurance;
|
||||
|
||||
$bookingDto = new BookingDto($this->createMockTravel(), 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
// Both insurances must already look eligible, otherwise the submit-time
|
||||
// reconciliation step (added for stale-insurance re-validation) would clear
|
||||
// them before propagation even runs - this test is only about propagation.
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn([$familyInsurance, $dependentInsurance]);
|
||||
$insuranceService->method('getEligibleInsurances')->willReturnCallback(
|
||||
fn (array $insurances) => $insurances
|
||||
);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([
|
||||
0 => $familyInsurance,
|
||||
1 => null,
|
||||
]);
|
||||
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(0.0);
|
||||
|
||||
$payloadBuilder = $this->createMock(BookingPayloadBuilder::class);
|
||||
$payloadBuilder->method('buildCreatePayload')->willReturn([]);
|
||||
|
||||
$processor = new BookingDataProcessor(
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
new ServiceMappingCollector(),
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
|
||||
$this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance);
|
||||
$this->assertNull($bookingDto->participants[1]->insurance, 'Dependent must lose their own insurance once the applicant selects family insurance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the edit-mode guard that protects an individually-locked non-family
|
||||
* insurance from being overwritten must not also block clearing a dependent's
|
||||
* pre-existing insurance when the applicant switches to family insurance.
|
||||
*/
|
||||
public function testFamilyInsuranceClearsDependentInsuranceInEditMode(): void
|
||||
{
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->id = 'family-1';
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$formData = $this->createCompleteFormData();
|
||||
|
||||
$applicant = $formData->participants[0];
|
||||
$applicant->bulkInsuranceBooking = false;
|
||||
$applicant->insurance = $familyInsurance;
|
||||
|
||||
$dependentInsurance = new Insurance();
|
||||
$dependentInsurance->id = 'dependent-own-1';
|
||||
$dependentInsurance->familyInsurance = false;
|
||||
|
||||
$dependent = $formData->participants[1];
|
||||
$dependent->insurance = $dependentInsurance;
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn([]);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([
|
||||
0 => $familyInsurance,
|
||||
1 => null,
|
||||
]);
|
||||
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
|
||||
$processor = new BookingDataProcessor(
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
new ServiceMappingCollector(),
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
new BookingPayloadBuilder(new ServiceMappingCollector()),
|
||||
new PersonalDataSynchronizer(),
|
||||
);
|
||||
|
||||
$processor->createUpdateRequestPayload($formData);
|
||||
|
||||
$this->assertSame($familyInsurance, $formData->participants[0]->insurance);
|
||||
$this->assertNull($formData->participants[1]->insurance, 'Dependent must lose their pre-existing individual insurance in edit mode once the applicant selects family insurance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: an insurance selected earlier can go stale by submission time (e.g. its
|
||||
* price tier no longer matches the current total, because the user added services on an
|
||||
* earlier step after choosing insurance and jumped straight to submission without
|
||||
* revisiting the insurance field). createBookingRequestPayload() must re-validate and
|
||||
* reassign to the correct tier right before the payload is built, so a stale ID never
|
||||
* reaches the BPN API.
|
||||
*/
|
||||
public function testCreateBookingRequestPayloadReassignsStaleInsuranceToCurrentPriceTier(): void
|
||||
{
|
||||
$staleInsurance = new Insurance();
|
||||
$staleInsurance->id = 'tier-low';
|
||||
$staleInsurance->familyInsurance = false;
|
||||
|
||||
$correctTierInsurance = new Insurance();
|
||||
$correctTierInsurance->id = 'tier-high';
|
||||
$correctTierInsurance->familyInsurance = false;
|
||||
|
||||
$applicant = $this->createMockParticipantDto(0, 'F');
|
||||
$applicant->insurance = $staleInsurance;
|
||||
|
||||
$bookingDto = new BookingDto($this->createMockTravel(), 1);
|
||||
$bookingDto->participants = [$applicant];
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn([$staleInsurance, $correctTierInsurance]);
|
||||
// Only the higher tier is eligible at the current (updated) price.
|
||||
$insuranceService->method('getEligibleInsurances')->willReturn([$correctTierInsurance]);
|
||||
$insuranceService->method('reassignInsuranceForPriceChange')->willReturn($correctTierInsurance);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => $correctTierInsurance]);
|
||||
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(2600.0);
|
||||
|
||||
$payloadBuilder = $this->createMock(BookingPayloadBuilder::class);
|
||||
$payloadBuilder->method('buildCreatePayload')->willReturn([]);
|
||||
|
||||
$processor = new BookingDataProcessor(
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
new ServiceMappingCollector(),
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
|
||||
$this->assertSame($correctTierInsurance, $applicant->insurance);
|
||||
$this->assertCount(1, $applicant->notifications);
|
||||
$this->assertSame('warning', array_values($applicant->notifications)[0]['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: when a stale insurance has no eligible replacement at all (e.g. the total
|
||||
* price now falls outside every tier of that type), it must be cleared rather than
|
||||
* silently left in place and submitted anyway.
|
||||
*/
|
||||
public function testCreateBookingRequestPayloadClearsStaleInsuranceWithoutEligibleReplacement(): void
|
||||
{
|
||||
$staleInsurance = new Insurance();
|
||||
$staleInsurance->id = 'tier-low';
|
||||
$staleInsurance->familyInsurance = false;
|
||||
|
||||
$applicant = $this->createMockParticipantDto(0, 'F');
|
||||
$applicant->insurance = $staleInsurance;
|
||||
|
||||
$bookingDto = new BookingDto($this->createMockTravel(), 1);
|
||||
$bookingDto->participants = [$applicant];
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn([$staleInsurance]);
|
||||
$insuranceService->method('getEligibleInsurances')->willReturn([]);
|
||||
$insuranceService->method('reassignInsuranceForPriceChange')->willReturn(null);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => null]);
|
||||
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(9999.0);
|
||||
|
||||
$payloadBuilder = $this->createMock(BookingPayloadBuilder::class);
|
||||
$payloadBuilder->method('buildCreatePayload')->willReturn([]);
|
||||
|
||||
$processor = new BookingDataProcessor(
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
new ServiceMappingCollector(),
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
|
||||
$this->assertNull($applicant->insurance);
|
||||
$this->assertCount(1, $applicant->notifications);
|
||||
$this->assertSame('warning', array_values($applicant->notifications)[0]['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: an insurance that's still eligible at submission time must be left
|
||||
* untouched - the reconciliation step must not fire (or notify) on the common,
|
||||
* unremarkable case where nothing has gone stale.
|
||||
*/
|
||||
public function testCreateBookingRequestPayloadLeavesStillEligibleInsuranceUntouched(): void
|
||||
{
|
||||
$insurance = new Insurance();
|
||||
$insurance->id = 'tier-1';
|
||||
$insurance->familyInsurance = false;
|
||||
|
||||
$applicant = $this->createMockParticipantDto(0, 'F');
|
||||
$applicant->insurance = $insurance;
|
||||
|
||||
$bookingDto = new BookingDto($this->createMockTravel(), 1);
|
||||
$bookingDto->participants = [$applicant];
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn([$insurance]);
|
||||
$insuranceService->method('getEligibleInsurances')->willReturn([$insurance]);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => $insurance]);
|
||||
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(500.0);
|
||||
|
||||
$payloadBuilder = $this->createMock(BookingPayloadBuilder::class);
|
||||
$payloadBuilder->method('buildCreatePayload')->willReturn([]);
|
||||
|
||||
$processor = new BookingDataProcessor(
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
new ServiceMappingCollector(),
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
|
||||
$this->assertSame($insurance, $applicant->insurance);
|
||||
$this->assertCount(0, $applicant->notifications);
|
||||
}
|
||||
}
|
||||
@@ -728,6 +728,54 @@ class ParticipantEditDtoTest extends TestCase
|
||||
$this->assertCount(1, $insuranceViolations);
|
||||
}
|
||||
|
||||
public function testDependentWithoutInsurancePassesWhenApplicantHasFamilyInsurance(): void
|
||||
{
|
||||
$applicant = $this->createAdultParticipant('[email protected]');
|
||||
$applicant->insurance = $this->createMockInsurance(familyInsurance: true);
|
||||
|
||||
$dependent = $this->createAdultParticipant('[email protected]');
|
||||
$dependent->insurance = null;
|
||||
|
||||
$bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]);
|
||||
|
||||
$wrapper = new ParticipantEditDto(
|
||||
participant: $bookingDto->participants[1],
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
$violations = $this->validator->validate($wrapper, null, ['strict_required']);
|
||||
$insuranceViolations = array_filter(
|
||||
iterator_to_array($violations),
|
||||
fn ($v) => 'participant.insurance' === $v->getPropertyPath()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $insuranceViolations);
|
||||
}
|
||||
|
||||
public function testDependentWithoutInsuranceFailsWhenApplicantHasNonFamilyInsurance(): void
|
||||
{
|
||||
$applicant = $this->createAdultParticipant('[email protected]');
|
||||
$applicant->insurance = $this->createMockInsurance(familyInsurance: false);
|
||||
|
||||
$dependent = $this->createAdultParticipant('[email protected]');
|
||||
$dependent->insurance = null;
|
||||
|
||||
$bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]);
|
||||
|
||||
$wrapper = new ParticipantEditDto(
|
||||
participant: $bookingDto->participants[1],
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
$violations = $this->validator->validate($wrapper, null, ['strict_required']);
|
||||
$insuranceViolations = array_filter(
|
||||
iterator_to_array($violations),
|
||||
fn ($v) => 'participant.insurance' === $v->getPropertyPath()
|
||||
);
|
||||
|
||||
$this->assertCount(1, $insuranceViolations);
|
||||
}
|
||||
|
||||
private function createBookingDtoWithParticipants(array $participants): BookingDto
|
||||
{
|
||||
$travel = new Travel();
|
||||
@@ -821,12 +869,13 @@ class ParticipantEditDtoTest extends TestCase
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function createMockInsurance(): Insurance
|
||||
private function createMockInsurance(bool $familyInsurance = false): Insurance
|
||||
{
|
||||
$insurance = new Insurance();
|
||||
$insurance->id = '1';
|
||||
$insurance->label = 'Test Insurance';
|
||||
$insurance->price = 10.0;
|
||||
$insurance->familyInsurance = $familyInsurance;
|
||||
|
||||
return $insurance;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service\Condition;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FamilyInsuranceActiveConditionTest extends TestCase
|
||||
{
|
||||
private FamilyInsuranceActiveCondition $condition;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->condition = new FamilyInsuranceActiveCondition();
|
||||
}
|
||||
|
||||
public function testAlwaysFalseForApplicant(): void
|
||||
{
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->insurance = $this->createFamilyInsurance();
|
||||
|
||||
$bookingDto = new BookingDto(new Travel(), 1);
|
||||
$bookingDto->participants = [$applicant];
|
||||
|
||||
$result = $this->condition->evaluate($bookingDto, 0, []);
|
||||
|
||||
$this->assertFalse($result, 'Applicant is never affected by their own family insurance selection');
|
||||
}
|
||||
|
||||
public function testTrueForDependentWhenApplicantHasFamilyInsurance(): void
|
||||
{
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->insurance = $this->createFamilyInsurance();
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
|
||||
$bookingDto = new BookingDto(new Travel(), 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$result = $this->condition->evaluate($bookingDto, 1, []);
|
||||
|
||||
$this->assertTrue($result, 'Dependent insurance field must be hidden when applicant has family insurance');
|
||||
}
|
||||
|
||||
public function testFalseForDependentWhenApplicantHasNonFamilyInsurance(): void
|
||||
{
|
||||
$nonFamilyInsurance = new Insurance();
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->insurance = $nonFamilyInsurance;
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
|
||||
$bookingDto = new BookingDto(new Travel(), 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$result = $this->condition->evaluate($bookingDto, 1, []);
|
||||
|
||||
$this->assertFalse($result, 'Dependent should keep their own insurance choice for non-family insurance');
|
||||
}
|
||||
|
||||
public function testFalseForDependentWhenApplicantHasNoInsurance(): void
|
||||
{
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
|
||||
$bookingDto = new BookingDto(new Travel(), 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$result = $this->condition->evaluate($bookingDto, 1, []);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testGetDependentFieldsReturnsInsurance(): void
|
||||
{
|
||||
$result = $this->condition->getDependentFields();
|
||||
|
||||
$this->assertSame(['insurance'], $result);
|
||||
}
|
||||
|
||||
public function testGetDescriptionReturnsString(): void
|
||||
{
|
||||
$result = $this->condition->getDescription();
|
||||
|
||||
$this->assertIsString($result);
|
||||
$this->assertStringContainsString('family insurance', $result);
|
||||
}
|
||||
|
||||
private function createFamilyInsurance(): Insurance
|
||||
{
|
||||
$insurance = new Insurance();
|
||||
$insurance->familyInsurance = true;
|
||||
|
||||
return $insurance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\CreateFieldStateProvider;
|
||||
use App\Service\ParticipantEligibilityChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Integration-level coverage for cross-participant field-state re-evaluation: proves the
|
||||
* dependent's 'insurance' field state actually flips when the applicant's insurance
|
||||
* selection changes, wiring FamilyInsuranceActiveCondition through the real
|
||||
* CreateFieldStateProvider composition rather than testing the condition in isolation.
|
||||
*/
|
||||
class CreateFieldStateProviderTest extends TestCase
|
||||
{
|
||||
private CreateFieldStateProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->provider = new CreateFieldStateProvider(new ParticipantEligibilityChecker());
|
||||
}
|
||||
|
||||
public function testDependentInsuranceFieldHidesWhenApplicantSelectsFamilyInsuranceThenReappearsAfterSwitch(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('+30 days');
|
||||
$travel->dateTo = new \DateTimeImmutable('+37 days');
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
// Baby age (0-2 years at travel date) is always eligible regardless of skipass availability
|
||||
$applicant->dateOfBirth = $travel->dateFrom->modify('-1 year');
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
$dependent->dateOfBirth = $travel->dateFrom->modify('-1 year');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$this->assertTrue(
|
||||
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
|
||||
'Dependent should see their own insurance field before the applicant selects family insurance'
|
||||
);
|
||||
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->familyInsurance = true;
|
||||
$applicant->insurance = $familyInsurance;
|
||||
|
||||
$this->assertFalse(
|
||||
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
|
||||
'Dependent insurance field must be hidden once the applicant selects family insurance'
|
||||
);
|
||||
|
||||
$nonFamilyInsurance = new Insurance();
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
$applicant->insurance = $nonFamilyInsurance;
|
||||
|
||||
$this->assertTrue(
|
||||
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
|
||||
'Dependent insurance field must reappear once the applicant switches away from family insurance'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use App\Service\ServiceAvailabilityCalculator;
|
||||
use App\Service\ServiceLabelFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Regression tests for the family-insurance choices-list bug: the applicant selects a
|
||||
* family insurance, it's correctly reassigned to the total-price tier on submit, but
|
||||
* the re-rendered form showed no selection because the choices list was still filtered
|
||||
* by the applicant's individual price instead of the total booking price.
|
||||
*/
|
||||
class ParticipantFieldOptionsProviderInsuranceTest extends TestCase
|
||||
{
|
||||
private ParticipantFieldOptionsProvider $provider;
|
||||
private BookingPriceCalculator $priceCalculatorService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
|
||||
// InsuranceManager is stateless - use the real implementation so actual
|
||||
// eligibility/price-tier filtering runs, not a stubbed-out mock.
|
||||
$insuranceService = new InsuranceManager();
|
||||
$this->priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
|
||||
$serviceLabelFormatter = new ServiceLabelFormatter();
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturnCallback(fn (string $message) => $message);
|
||||
|
||||
$this->provider = new ParticipantFieldOptionsProvider(
|
||||
$serviceAvailabilityCalculator,
|
||||
$insuranceService,
|
||||
$this->priceCalculatorService,
|
||||
$serviceLabelFormatter,
|
||||
$translator
|
||||
);
|
||||
}
|
||||
|
||||
public function testFamilyInsuranceCorrectTierAppearsInApplicantChoicesUsingTotalPrice(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
|
||||
// Low tier only matches the applicant's individual price (300), not the total (450)
|
||||
$lowTierFamilyInsurance = new Insurance();
|
||||
$lowTierFamilyInsurance->id = '1';
|
||||
$lowTierFamilyInsurance->label = 'Reise-Rücktritt Familie';
|
||||
$lowTierFamilyInsurance->familyInsurance = true;
|
||||
$lowTierFamilyInsurance->travelPriceFrom = 0.0;
|
||||
$lowTierFamilyInsurance->travelPriceTo = 400.0;
|
||||
|
||||
// Correct tier matches the total booking price (450), not the applicant's individual price (300)
|
||||
$correctTierFamilyInsurance = new Insurance();
|
||||
$correctTierFamilyInsurance->id = '2';
|
||||
$correctTierFamilyInsurance->label = 'Reise-Rücktritt Familie';
|
||||
$correctTierFamilyInsurance->familyInsurance = true;
|
||||
$correctTierFamilyInsurance->travelPriceFrom = 400.01;
|
||||
$correctTierFamilyInsurance->travelPriceTo = 600.0;
|
||||
|
||||
$travel->insurances = [$lowTierFamilyInsurance, $correctTierFamilyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
|
||||
$this->priceCalculatorService
|
||||
->method('calculateIndividualParticipantPriceExcludingInsurance')
|
||||
->willReturn(300.0);
|
||||
$this->priceCalculatorService
|
||||
->method('calculateTotalBookingPriceExcludingInsurance')
|
||||
->willReturn(450.0);
|
||||
|
||||
$options = $this->provider->getFieldOptions('insurance', $bookingDto, 0);
|
||||
|
||||
$choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']);
|
||||
|
||||
$this->assertContains('2', $choiceIds, 'Family insurance tier matching the total booking price must be selectable');
|
||||
$this->assertNotContains('1', $choiceIds, 'Family insurance tier that only matches the individual price must not be selectable');
|
||||
}
|
||||
|
||||
public function testFamilyInsuranceNeverAppearsInDependentChoices(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->id = '1';
|
||||
$familyInsurance->label = 'Reise-Rücktritt Familie';
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$travel->insurances = [$familyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
|
||||
$this->priceCalculatorService
|
||||
->method('calculateIndividualParticipantPriceExcludingInsurance')
|
||||
->willReturn(0.0);
|
||||
$this->priceCalculatorService
|
||||
->method('calculateTotalBookingPriceExcludingInsurance')
|
||||
->willReturn(450.0);
|
||||
|
||||
$options = $this->provider->getFieldOptions('insurance', $bookingDto, 1);
|
||||
|
||||
$choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']);
|
||||
|
||||
$this->assertNotContains('1', $choiceIds, 'Family insurance must never be selectable for a non-applicant participant');
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
|
||||
// Mock price calculator to return a default price
|
||||
$this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')
|
||||
->willReturn(500.0);
|
||||
$this->priceCalculatorService->method('resolveInsuranceTravelPrice')
|
||||
->willReturn(500.0);
|
||||
|
||||
$this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService);
|
||||
}
|
||||
@@ -196,6 +198,36 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
|
||||
$this->assertNull($participant->insurance);
|
||||
}
|
||||
|
||||
public function testProcessFieldSwitchingToNewInsuranceIsNotOverwrittenByStaleResubmissionCheck(): void
|
||||
{
|
||||
// Regression: switching from one already-selected insurance to a different one must
|
||||
// not be re-validated (and potentially overwritten) against the OLD, now-stale
|
||||
// insurance by the "form resubmission" block afterward.
|
||||
$oldInsurance = $this->createInsurance('1');
|
||||
$newInsurance = $this->createInsurance('2');
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->insurance = $oldInsurance;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->insurances = [$oldInsurance, $newInsurance];
|
||||
|
||||
$bookingDto = $this->createMockBookingDto();
|
||||
$bookingDto->method('getParticipant')->with(0)->willReturn($participant);
|
||||
$bookingDto->travel = $travel;
|
||||
|
||||
// Only the NEW insurance is ever eligible - the old one would fail re-validation
|
||||
// if the (buggy) resubmission block ran a second time after this new selection.
|
||||
$this->insuranceService
|
||||
->expects($this->once())
|
||||
->method('getEligibleInsurances')
|
||||
->willReturn([$newInsurance]);
|
||||
|
||||
$this->handler->processField(['insurance' => '2'], $bookingDto, 0);
|
||||
|
||||
$this->assertSame($newInsurance, $participant->insurance);
|
||||
}
|
||||
|
||||
public function testProcessFieldWorksWithStringAndIntegerIds(): void
|
||||
{
|
||||
$insurance = $this->createInsurance(123); // Integer ID
|
||||
@@ -247,6 +279,250 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
|
||||
$this->assertNull($participant->insurance);
|
||||
}
|
||||
|
||||
public function testProcessFieldNotifiesApplicantWhenFamilyInsuranceNewlyAvailable(): void
|
||||
{
|
||||
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2', 'Reise-Rücktritt Familie')]);
|
||||
|
||||
// Dependent's own card is submitted (e.g. just their date of birth) - no insurance selection
|
||||
$this->handler->processField([], $bookingDto, 1);
|
||||
|
||||
$this->assertNotEmpty($applicant->notifications, 'Applicant should be notified that family insurance is now available');
|
||||
$this->assertTrue($bookingDto->familyInsuranceHintShown);
|
||||
}
|
||||
|
||||
public function testProcessFieldRecordsIneligibilityWhenApplicantSelectsInsuranceBeforeFamilyEligible(): void
|
||||
{
|
||||
$nonFamilyInsurance = $this->createInsurance('1');
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
$travel->insurances = [$nonFamilyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
|
||||
// No dependents yet - not (and cannot be) a family booking at selection time
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant];
|
||||
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturn([$nonFamilyInsurance]);
|
||||
|
||||
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
|
||||
|
||||
$this->assertTrue(
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
|
||||
'Choosing non-family insurance while ineligible must be recorded so a later hint is justified'
|
||||
);
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotRecordIneligibilityWhenApplicantSelectsInsuranceWhileAlreadyFamilyEligible(): void
|
||||
{
|
||||
$nonFamilyInsurance = $this->createInsurance('1');
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$familyInsurance = $this->createInsurance('2');
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
// Dependent's date of birth is already known, and a family insurance product is
|
||||
// actually eligible at the current price - family insurance was genuinely
|
||||
// available at the moment the applicant deliberately chose a non-family insurance.
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
|
||||
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturnCallback(
|
||||
fn (array $insurances) => $insurances
|
||||
);
|
||||
|
||||
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
|
||||
|
||||
$this->assertFalse(
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
|
||||
'Must not claim family insurance "just became available" when it was already eligible at selection time'
|
||||
);
|
||||
}
|
||||
|
||||
public function testProcessFieldRecordsIneligibilityWhenFamilyCompositionExistsButNoPriceTierMatches(): void
|
||||
{
|
||||
// Regression: family composition (adult + child) alone isn't enough - a family
|
||||
// insurance product must actually match the current total price for it to count
|
||||
// as "eligible". If it doesn't, the applicant's choice was genuinely made while
|
||||
// family insurance was NOT selectable, even though isFamilyBooking() is already true.
|
||||
$nonFamilyInsurance = $this->createInsurance('1');
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$familyInsurance = $this->createInsurance('2');
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
|
||||
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturnCallback(
|
||||
// No family insurance matches the current price tier, even though the
|
||||
// participant composition already qualifies as a family booking.
|
||||
fn (array $insurances) => array_values(array_filter($insurances, fn ($i) => false === $i->familyInsurance))
|
||||
);
|
||||
|
||||
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
|
||||
|
||||
$this->assertTrue(
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
|
||||
'Family composition alone must not count as eligible - no family insurance actually matched the price'
|
||||
);
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotNotifyWhenApplicantSwitchesToFamilyInsuranceInSameSubmission(): void
|
||||
{
|
||||
$familyInsurance = $this->createInsurance('2');
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$nonFamilyInsurance = $this->createInsurance('1');
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
$applicant->insurance = $nonFamilyInsurance;
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true;
|
||||
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturn([$familyInsurance]);
|
||||
|
||||
// Applicant's own submission switches them to family insurance in this same request
|
||||
$this->handler->processField(['insurance' => '2'], $bookingDto, 0);
|
||||
|
||||
$this->assertSame($familyInsurance, $applicant->insurance);
|
||||
$this->assertEmpty($applicant->notifications, 'Must not show a stale "please recheck" hint for a choice this submission already made');
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotNotifyWhenHintAlreadyShown(): void
|
||||
{
|
||||
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
|
||||
$bookingDto->familyInsuranceHintShown = true;
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2')]);
|
||||
|
||||
$this->handler->processField([], $bookingDto, 1);
|
||||
|
||||
$this->assertEmpty($applicant->notifications, 'Hint must not repeat once already shown');
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotNotifyWhenApplicantHasNoInsurance(): void
|
||||
{
|
||||
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
$applicant->insurance = null;
|
||||
|
||||
$this->handler->processField([], $bookingDto, 1);
|
||||
|
||||
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant has not chosen insurance yet');
|
||||
$this->assertFalse($bookingDto->familyInsuranceHintShown);
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotNotifyWhenApplicantAlreadyHasFamilyInsurance(): void
|
||||
{
|
||||
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
$familyInsurance = $this->createInsurance('2');
|
||||
$familyInsurance->familyInsurance = true;
|
||||
$applicant->insurance = $familyInsurance;
|
||||
|
||||
$this->handler->processField([], $bookingDto, 1);
|
||||
|
||||
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant already has family insurance');
|
||||
$this->assertFalse($bookingDto->familyInsuranceHintShown);
|
||||
}
|
||||
|
||||
public function testProcessFieldDoesNotNotifyWhenNoEligibleFamilyInsuranceExists(): void
|
||||
{
|
||||
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
|
||||
$this->insuranceService->method('getEligibleInsurances')->willReturn([]); // No eligible family insurance
|
||||
|
||||
$this->handler->processField([], $bookingDto, 1);
|
||||
|
||||
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when no family insurance is actually eligible');
|
||||
$this->assertFalse($bookingDto->familyInsuranceHintShown);
|
||||
}
|
||||
|
||||
private function createFamilyBookingDtoWithNonFamilyApplicantInsurance(): BookingDto
|
||||
{
|
||||
$nonFamilyInsurance = $this->createInsurance('1');
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$familyInsurance = $this->createInsurance('2');
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
|
||||
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
|
||||
$applicant->insurance = $nonFamilyInsurance;
|
||||
|
||||
$child = new ParticipantDto();
|
||||
$child->index = 1;
|
||||
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $child];
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true;
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
public function testGetFieldStateModificationsReturnsEmptyArray(): void
|
||||
{
|
||||
$bookingDto = $this->createMockBookingDto();
|
||||
|
||||
@@ -252,4 +252,86 @@ class BookingPriceCalculatorTest extends TestCase
|
||||
'calculateServiceTotal() must agree with the display breakdown on bulk-insurance bookings'
|
||||
);
|
||||
}
|
||||
|
||||
public function testResolveInsuranceTravelPriceUsesIndividualPriceForNonFamilyInsurance(): void
|
||||
{
|
||||
$skiPass = new Service();
|
||||
$skiPass->price = 50.0;
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->skiPass = $skiPass;
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
|
||||
$travel = new Travel();
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$nonFamilyInsurance = new Insurance();
|
||||
$nonFamilyInsurance->familyInsurance = false;
|
||||
|
||||
$result = $this->service->resolveInsuranceTravelPrice($bookingDto, 0, $nonFamilyInsurance);
|
||||
|
||||
$this->assertEquals(50.0, $result);
|
||||
}
|
||||
|
||||
public function testResolveInsuranceTravelPriceUsesTotalPriceForFamilyInsuranceOnApplicant(): void
|
||||
{
|
||||
$applicantSkiPass = new Service();
|
||||
$applicantSkiPass->price = 50.0;
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->skiPass = $applicantSkiPass;
|
||||
|
||||
$dependentSkiPass = new Service();
|
||||
$dependentSkiPass->price = 30.0;
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
$dependent->skiPass = $dependentSkiPass;
|
||||
|
||||
$travel = new Travel();
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$result = $this->service->resolveInsuranceTravelPrice($bookingDto, 0, $familyInsurance);
|
||||
|
||||
$this->assertEquals(80.0, $result);
|
||||
}
|
||||
|
||||
public function testResolveInsuranceTravelPriceUsesTotalPriceForFamilyInsuranceRegardlessOfParticipantIndex(): void
|
||||
{
|
||||
// Family insurances are restricted to the applicant by InsuranceManager's eligibility
|
||||
// constraints, not by this method - it always resolves the total price for them.
|
||||
$applicantSkiPass = new Service();
|
||||
$applicantSkiPass->price = 50.0;
|
||||
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->skiPass = $applicantSkiPass;
|
||||
|
||||
$dependentSkiPass = new Service();
|
||||
$dependentSkiPass->price = 30.0;
|
||||
|
||||
$dependent = new ParticipantDto();
|
||||
$dependent->index = 1;
|
||||
$dependent->skiPass = $dependentSkiPass;
|
||||
|
||||
$travel = new Travel();
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->participants = [$applicant, $dependent];
|
||||
|
||||
$familyInsurance = new Insurance();
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$result = $this->service->resolveInsuranceTravelPrice($bookingDto, 1, $familyInsurance);
|
||||
|
||||
$this->assertEquals(80.0, $result);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class BpnXmlAnonymizerTest extends TestCase
|
||||
<anmelder>
|
||||
<name>Example</name>
|
||||
<vorname>Ava</vorname>
|
||||
<geburtsdatum>03.04.1991</geburtsdatum>
|
||||
<anschrift>
|
||||
<strasse>Testallee 7</strasse>
|
||||
<plz>10115</plz>
|
||||
@@ -32,6 +33,7 @@ class BpnXmlAnonymizerTest extends TestCase
|
||||
<teilnehmer id="1">
|
||||
<name>Example</name>
|
||||
<vorname>Ava</vorname>
|
||||
<geburtsdatum>03.04.1991</geburtsdatum>
|
||||
<anschrift>
|
||||
<strasse>Testallee 7</strasse>
|
||||
<plz>10115</plz>
|
||||
@@ -46,6 +48,7 @@ class BpnXmlAnonymizerTest extends TestCase
|
||||
<teilnehmer id="2">
|
||||
<name>Second</name>
|
||||
<vorname>Ben</vorname>
|
||||
<geburtsdatum>12.11.1987</geburtsdatum>
|
||||
<kommunikation>
|
||||
<email>[email protected]</email>
|
||||
</kommunikation>
|
||||
@@ -69,6 +72,9 @@ XML;
|
||||
$applicantMobilePhone = trim((string) $xpath->evaluate('string(//anmelder/kommunikation/telefonmobil)'));
|
||||
$participantOneMobilePhone = trim((string) $xpath->evaluate('string(//teilnehmer[1]/kommunikation/telefonmobil)'));
|
||||
$participantTwoName = trim((string) $xpath->evaluate('string(//teilnehmer[2]/name)'));
|
||||
$applicantBirthDate = trim((string) $xpath->evaluate('string(//anmelder/geburtsdatum)'));
|
||||
$participantOneBirthDate = trim((string) $xpath->evaluate('string(//teilnehmer[1]/geburtsdatum)'));
|
||||
$participantTwoBirthDate = trim((string) $xpath->evaluate('string(//teilnehmer[2]/geburtsdatum)'));
|
||||
|
||||
$this->assertNotSame('Example', $applicantName);
|
||||
$this->assertSame($applicantName, $participantOneName);
|
||||
@@ -76,6 +82,9 @@ XML;
|
||||
$this->assertSame('01000000002', $participantOneMobilePhone);
|
||||
$this->assertSame($applicantMobilePhone, $participantOneMobilePhone);
|
||||
$this->assertNotSame($participantOneName, $participantTwoName);
|
||||
$this->assertSame('03.04.1991', $applicantBirthDate);
|
||||
$this->assertSame('03.04.1991', $participantOneBirthDate);
|
||||
$this->assertSame('12.11.1987', $participantTwoBirthDate);
|
||||
$this->assertSame(3, $anonymizer->getLastIdentityCount());
|
||||
}
|
||||
|
||||
@@ -181,7 +190,7 @@ XML;
|
||||
$this->assertSame('Divers', $responseSalutation);
|
||||
$this->assertSame('', $responseTitle);
|
||||
$this->assertSame('D', $responseGender);
|
||||
$this->assertSame('01.01.1980', $responseBirthDate);
|
||||
$this->assertSame('16.04.1972', $responseBirthDate);
|
||||
$this->assertSame('XX', $responseNationality);
|
||||
$this->assertNotSame('Beispielweg 57', $responseStreet);
|
||||
$this->assertNotSame('42853', $responsePostalCode);
|
||||
@@ -501,7 +510,7 @@ XML;
|
||||
$this->assertSame('Divers', $salutation);
|
||||
$this->assertSame('', $title);
|
||||
$this->assertSame('D', $gender);
|
||||
$this->assertSame('01.01.1980', $birthDate);
|
||||
$this->assertSame('01.02.1990', $birthDate);
|
||||
$this->assertSame('XX', $nationality);
|
||||
$this->assertNotSame('Beispielweg 12', $street);
|
||||
$this->assertNotSame('12345', $postalCode);
|
||||
|
||||
@@ -425,6 +425,148 @@ class InsuranceManagerTest extends TestCase
|
||||
$this->assertNotNull($result[1]);
|
||||
}
|
||||
|
||||
public function testBatchAssignFamilyInsuranceToApplicantOnly(): void
|
||||
{
|
||||
$booking = $this->createFamilyBooking('2024-08-01', '2024-08-08');
|
||||
|
||||
// Tier matched by total price (300 + 0 = 300, fits 250–400 tier)
|
||||
$lowTierInsurance = $this->createInsurance([
|
||||
'id' => '1',
|
||||
'package' => false,
|
||||
'subType' => 'FAM',
|
||||
'familyInsurance' => true,
|
||||
'travelPriceFrom' => 0.0,
|
||||
'travelPriceTo' => 249.99,
|
||||
]);
|
||||
$correctTierInsurance = $this->createInsurance([
|
||||
'id' => '2',
|
||||
'package' => false,
|
||||
'subType' => 'FAM',
|
||||
'familyInsurance' => true,
|
||||
'travelPriceFrom' => 250.0,
|
||||
'travelPriceTo' => 400.0,
|
||||
]);
|
||||
|
||||
$availableInsurances = [$lowTierInsurance, $correctTierInsurance];
|
||||
// Individual prices: applicant 300, child 0 → total 300
|
||||
$participantPrices = [0 => 300.0, 1 => 0.0];
|
||||
|
||||
$result = $this->service->batchAssignInsuranceToParticipants(
|
||||
$availableInsurances,
|
||||
$correctTierInsurance,
|
||||
$booking,
|
||||
$participantPrices
|
||||
);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertSame($correctTierInsurance, $result[0], 'Applicant should get tier matching total price');
|
||||
$this->assertNull($result[1], 'Non-applicant participants must be cleared for family insurance');
|
||||
}
|
||||
|
||||
public function testFamilyInsuranceIneligibleForNonApplicant(): void
|
||||
{
|
||||
$booking = $this->createFamilyBooking('2024-08-01', '2024-08-08');
|
||||
|
||||
$nonApplicant = $this->createParticipant('2015-01-01'); // child
|
||||
$nonApplicant->index = 1;
|
||||
|
||||
$familyInsurance = $this->createInsurance(['familyInsurance' => true]);
|
||||
|
||||
$result = $this->service->getEligibleInsurances([$familyInsurance], $nonApplicant, $booking, 500.0);
|
||||
|
||||
$this->assertEmpty($result, 'Family insurance must not be eligible for non-applicant participants');
|
||||
}
|
||||
|
||||
public function testNonFamilyInsuranceEligibleForFamilyBooking(): void
|
||||
{
|
||||
$booking = $this->createFamilyBooking('2024-08-01', '2024-08-08');
|
||||
|
||||
$adult = $this->createParticipant('1990-06-15');
|
||||
$adult->index = 0;
|
||||
$child = $this->createParticipant('2015-01-01');
|
||||
$child->index = 1;
|
||||
|
||||
$nonFamilyInsurance = $this->createInsurance(['familyInsurance' => false]);
|
||||
|
||||
$adultResult = $this->service->getEligibleInsurances([$nonFamilyInsurance], $adult, $booking, 500.0);
|
||||
$childResult = $this->service->getEligibleInsurances([$nonFamilyInsurance], $child, $booking, 500.0);
|
||||
|
||||
$this->assertNotEmpty($adultResult, 'Non-family insurance must be available in family bookings for adults');
|
||||
$this->assertNotEmpty($childResult, 'Non-family insurance must be available in family bookings for children');
|
||||
}
|
||||
|
||||
public function testFamilyInsuranceIneligibleForNonFamilyBooking(): void
|
||||
{
|
||||
// Two adults, no children — not a family booking
|
||||
$booking = $this->createBooking('2024-08-01', '2024-08-08');
|
||||
$adult1 = $this->createParticipant('1990-06-15');
|
||||
$adult1->index = 0;
|
||||
$adult2 = $this->createParticipant('1985-03-10');
|
||||
$adult2->index = 1;
|
||||
$booking->participants = [$adult1, $adult2];
|
||||
|
||||
$familyInsurance = $this->createInsurance(['familyInsurance' => true]);
|
||||
|
||||
$result = $this->service->getEligibleInsurances([$familyInsurance], $adult1, $booking, 500.0);
|
||||
|
||||
$this->assertEmpty($result, 'Family insurance must not be eligible when booking is not a family booking');
|
||||
}
|
||||
|
||||
public function testGetEligibleInsurancesForParticipantUsesTotalPriceForFamilyInsurance(): void
|
||||
{
|
||||
$booking = $this->createFamilyBooking('2024-08-01', '2024-08-08');
|
||||
$applicant = $booking->participants[0];
|
||||
|
||||
// Only matches the total booking price (450), not the individual price (300)
|
||||
$familyInsurance = $this->createInsurance([
|
||||
'familyInsurance' => true,
|
||||
'travelPriceFrom' => 400.01,
|
||||
'travelPriceTo' => 600.0,
|
||||
]);
|
||||
|
||||
$nonFamilyInsurance = $this->createInsurance([
|
||||
'familyInsurance' => false,
|
||||
'travelPriceFrom' => 0.0,
|
||||
'travelPriceTo' => 350.0,
|
||||
]);
|
||||
|
||||
$result = $this->service->getEligibleInsurancesForParticipant(
|
||||
[$familyInsurance, $nonFamilyInsurance],
|
||||
$applicant,
|
||||
$booking,
|
||||
300.0,
|
||||
450.0
|
||||
);
|
||||
|
||||
$ids = array_map(fn (Insurance $insurance) => spl_object_id($insurance), $result);
|
||||
|
||||
$this->assertContains(spl_object_id($familyInsurance), $ids, 'Family insurance must be evaluated against the total booking price');
|
||||
$this->assertContains(spl_object_id($nonFamilyInsurance), $ids, 'Non-family insurance must be evaluated against the individual price');
|
||||
}
|
||||
|
||||
public function testGetEligibleInsurancesForParticipantSkipsFamilyPricingWhenNoneAvailable(): void
|
||||
{
|
||||
$booking = $this->createFamilyBooking('2024-08-01', '2024-08-08');
|
||||
$applicant = $booking->participants[0];
|
||||
|
||||
$nonFamilyInsurance = $this->createInsurance([
|
||||
'familyInsurance' => false,
|
||||
'travelPriceFrom' => 0.0,
|
||||
'travelPriceTo' => 350.0,
|
||||
]);
|
||||
|
||||
$result = $this->service->getEligibleInsurancesForParticipant(
|
||||
[$nonFamilyInsurance],
|
||||
$applicant,
|
||||
$booking,
|
||||
300.0,
|
||||
450.0
|
||||
);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame($nonFamilyInsurance, $result[0]);
|
||||
}
|
||||
|
||||
private function createParticipant(string $dateOfBirth): ParticipantDto
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
@@ -442,6 +584,20 @@ class InsuranceManagerTest extends TestCase
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function createFamilyBooking(string $travelDateFrom, string $travelDateTo): BookingDto
|
||||
{
|
||||
$booking = $this->createBooking($travelDateFrom, $travelDateTo);
|
||||
|
||||
$adult = $this->createParticipant('1990-06-15'); // 34 years old
|
||||
$adult->index = 0;
|
||||
$child = $this->createParticipant('2015-01-01'); // 9 years old
|
||||
$child->index = 1;
|
||||
|
||||
$booking->participants = [$adult, $child];
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function createTravel(string $dateFrom, string $dateTo): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
Reference in new issue
Block a user