wip: insurance booking

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 8af7f0e797
commit 4ba81b8f03
19 changed files with 807 additions and 128 deletions
+88 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto;
@@ -238,6 +239,45 @@ class BookingPriceCalculatorService
return $participantPrices;
}
/**
* Calculates the total price for an individual participant excluding insurance.
*
* This method is used for insurance eligibility filtering to avoid circular dependency
* where insurance selection affects travel price which affects insurance eligibility.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
* @param int $participantIndex The index of the participant to calculate for
*
* @return float The total price for the specified participant excluding insurance
*/
public function calculateIndividualParticipantPriceExcludingInsurance(BookingDtoInterface $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
}
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return 0.0;
}
$totalPrice = 0.0;
// Add room price if participant is assigned to a room
if (null !== $participant->assignedRoomId) {
$room = $this->getRoomById($bookingDto, $participant->assignedRoomId);
if (null !== $room && null !== $room->price) {
// Each participant pays the full room price
$totalPrice += $room->price;
}
}
// Add service prices for this participant (excluding insurance)
$totalPrice += $this->calculateParticipantServiceTotal($participant, false);
return $totalPrice;
}
/**
* Aggregates all transportation-related services and pricing into separate line items.
*
@@ -311,7 +351,7 @@ class BookingPriceCalculatorService
'unitPrice' => null,
'participantCount' => $pickupParticipants,
'totalPrice' => $pickupTotal,
'subType' => 'transportation',
'subType' => Constants::GROUP_TRANSPORTATION,
];
}
@@ -323,7 +363,7 @@ class BookingPriceCalculatorService
'unitPrice' => null,
'participantCount' => $parkingParticipants,
'totalPrice' => $parkingTotal,
'subType' => 'transportation',
'subType' => Constants::GROUP_TRANSPORTATION,
];
}
@@ -356,7 +396,12 @@ class BookingPriceCalculatorService
// Normalize rental subtypes to avoid duplicate sections
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
$subType = 'rentals'; // Normalize all rental subtypes to a single key
$subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key
}
// Normalize insurance subtypes to avoid duplicate sections
if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) {
$subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key
}
$isDiscount = $serviceData['totalPrice'] < 0;
@@ -405,8 +450,9 @@ class BookingPriceCalculatorService
Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen',
Constants::TOKEN_BOARD => 'Verpflegung',
Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
'transportation' => 'Beförderung',
'rentals' => 'Leihmaterial', // Normalized rental subtype
Constants::GROUP_TRANSPORTATION => 'Beförderung',
Constants::GROUP_RENTALS => 'Leihmaterial', // Normalized rental subtype
Constants::GROUP_INSURANCE => 'Reiseversicherungen', // Normalized insurance subtype
];
// Handle rentals array (keep for backward compatibility with non-normalized subtypes)
@@ -431,6 +477,10 @@ class BookingPriceCalculatorService
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1);
}
if (null !== $participant->insurance && null !== $participant->insurance->price) {
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1);
}
// Handle multiple service selections
$multipleServiceArrays = [
'courses' => $participant->courses,
@@ -475,11 +525,12 @@ class BookingPriceCalculatorService
/**
* Calculates the total service cost for a single participant.
*
* @param ParticipantDto $participant The participant to calculate services for
* @param ParticipantDto $participant The participant to calculate services for
* @param bool $includeInsurance Whether to include insurance pricing (default: true)
*
* @return float The total service cost for this participant
*/
private function calculateParticipantServiceTotal(ParticipantDto $participant): float
private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float
{
$serviceTotal = 0.0;
@@ -492,6 +543,10 @@ class BookingPriceCalculatorService
$serviceTotal += $participant->rentalInsurance->price;
}
if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) {
$serviceTotal += $participant->insurance->price;
}
// Transportation services
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
$serviceTotal += $participant->transportationOutbound->price;
@@ -551,4 +606,30 @@ class BookingPriceCalculatorService
return null;
}
/**
* Adds an insurance to the service aggregation array.
*
* @param array $serviceAggregation The service aggregation array to update
* @param Insurance $insurance The insurance to add
* @param int $quantity The quantity of the insurance
*/
private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void
{
$serviceKey = $insurance->id.'_'.$insurance->label;
if (false === isset($serviceAggregation[$serviceKey])) {
$serviceAggregation[$serviceKey] = [
'serviceId' => $insurance->id,
'label' => $insurance->label,
'unitPrice' => $insurance->price,
'participantCount' => 0,
'totalPrice' => 0.0,
'subType' => $insurance->getSubType(),
];
}
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
}
}
+160 -43
View File
@@ -7,6 +7,9 @@ namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto;
use App\Model\InsuranceEligibilityCriteria;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceTypeResolver;
use Carbon\Carbon;
/**
@@ -18,6 +21,11 @@ use Carbon\Carbon;
*/
class InsuranceMatchingService
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly InsuranceTypeResolver $insuranceTypeResolver
) {
}
/**
* Filters insurances based on participant and booking criteria.
*
@@ -29,67 +37,151 @@ class InsuranceMatchingService
*/
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array
{
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
$criteria = $this->createEligibilityCriteria($participant, $booking);
// Cannot match insurances without travel dates
if (null === $travelStartDate || null === $travelEndDate) {
return [];
if (null === $criteria) {
return []; // Cannot match insurances without travel dates
}
$bookingDate = Carbon::now()->toDateTimeImmutable();
$travelPrice = $this->calculateTravelPrice($booking);
$travelDurationDays = $this->calculateTravelDurationDays($travelStartDate, $travelEndDate);
return array_filter($insurances, function (Insurance $insurance) use ($participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays) {
return $this->isInsuranceEligible($insurance, $participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays);
});
return array_filter(
$insurances,
fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria)
);
}
/**
* Checks if a specific insurance is eligible for a participant.
* Auto-reassigns an insurance to the same type with appropriate price tier.
*
* @param Insurance $insurance The insurance to check
* @param ParticipantDto $participant The participant
* @param \DateTimeImmutable $travelStartDate Travel start date
* @param \DateTimeImmutable $travelEndDate Travel end date
* @param \DateTimeImmutable $bookingDate Booking date
* @param float $travelPrice Total travel price
* @param int $travelDurationDays Travel duration in days
* This method is used when a participant's individual price changes and their
* current insurance is no longer eligible. It finds the same insurance type
* (subType + familyInsurance) with the correct price tier.
*
* @return bool True if insurance is eligible
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $currentInsurance The currently selected insurance
* @param ParticipantDto $participant The participant to reassign for
* @param BookingCreateDto $booking The booking context
*
* @return Insurance|null The reassigned insurance or null if no suitable match found
*/
private function isInsuranceEligible(
Insurance $insurance,
ParticipantDto $participant,
\DateTimeImmutable $travelStartDate,
\DateTimeImmutable $travelEndDate,
\DateTimeImmutable $bookingDate,
float $travelPrice,
int $travelDurationDays,
): bool {
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance
{
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance->subType, $currentInsurance->familyInsurance);
// Get eligible insurances for this participant
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
// Return the first eligible insurance (they should all be equivalent for the same type)
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
/**
* Batch-assigns insurances of the same type to all participants based on individual pricing.
*
* This method takes the applicant's insurance selection and assigns the same insurance type
* (subType + familyInsurance) to all participants, but selects the appropriate price tier
* based on each participant's individual travel price.
*
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingCreateDto $booking The booking with all participants
*
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*/
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingCreateDto $booking): array
{
$assignments = [];
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance->subType, $selectedInsurance->familyInsurance);
// Assign appropriate insurance to each participant
foreach ($booking->getParticipants() as $index => $participant) {
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
$assignments[$index] = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
return $assignments;
}
/**
* Creates eligibility criteria from participant and booking data.
*/
private function createEligibilityCriteria(ParticipantDto $participant, BookingCreateDto $booking): ?InsuranceEligibilityCriteria
{
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
if (null === $travelStartDate || null === $travelEndDate) {
return null; // Cannot create criteria without travel dates
}
return new InsuranceEligibilityCriteria(
participant: $participant,
travelStartDate: $travelStartDate,
travelEndDate: $travelEndDate,
bookingDate: Carbon::now()->toDateTimeImmutable(),
travelPrice: $this->calculateTravelPrice($booking, $participant->index),
travelDurationDays: $this->calculateTravelDurationDays($travelStartDate, $travelEndDate),
booking: $booking,
);
}
/**
* Checks if a specific insurance is eligible for given criteria.
*/
private function isInsuranceEligible(Insurance $insurance, InsuranceEligibilityCriteria $criteria): bool
{
// Family insurance constraints
if (false === $this->checkFamilyInsuranceConstraints($insurance, $criteria->booking)) {
return false;
}
// Age constraints
if (!$this->checkAgeConstraints($insurance, $participant, $travelStartDate)) {
if (false === $this->checkAgeConstraints($insurance, $criteria->participant, $criteria->travelStartDate)) {
return false;
}
// Travel date constraints
if (!$this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) {
if (false === $this->checkTravelDateConstraints($insurance, $criteria->travelStartDate, $criteria->travelEndDate)) {
return false;
}
// Booking date constraints
if (!$this->checkBookingDateConstraints($insurance, $bookingDate)) {
if (false === $this->checkBookingDateConstraints($insurance, $criteria->bookingDate)) {
return false;
}
// Travel price constraints
if (!$this->checkTravelPriceConstraints($insurance, $travelPrice)) {
if (false === $this->checkTravelPriceConstraints($insurance, $criteria->travelPrice)) {
return false;
}
// Travel duration constraints
if (!$this->checkTravelDurationConstraints($insurance, $travelDurationDays)) {
if (false === $this->checkTravelDurationConstraints($insurance, $criteria->travelDurationDays)) {
return false;
}
return true;
}
/**
* Checks if family insurance constraints are met.
*
* Family insurances should only be available for family bookings,
* and individual insurances should only be available for non-family bookings.
*/
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingCreateDto $booking): bool
{
$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;
}
@@ -103,8 +195,9 @@ class InsuranceMatchingService
{
$participantAge = $participant->getAge($travelStartDate);
// If no birth date is provided, skip age constraints (field will be hidden via field state conditions)
if (null === $participantAge) {
return false; // Cannot determine age eligibility without birth date
return true;
}
// Check minimum age
@@ -197,17 +290,21 @@ class InsuranceMatchingService
}
/**
* Calculates the total travel price from booking data.
* Calculates the total travel price for a participant, excluding insurance prices.
*
* @param BookingCreateDto $booking The booking to calculate price for
* This method calculates the travel price used for insurance eligibility filtering.
* It excludes insurance prices to prevent circular dependency where insurance selection
* affects travel price which then affects insurance eligibility.
*
* @return float The total travel price
* @param BookingCreateDto $booking The booking to calculate price for
* @param int $participantIndex The participant index to calculate for
*
* @return float The total travel price for the participant excluding insurance
*/
private function calculateTravelPrice(BookingCreateDto $booking): float
private function calculateTravelPrice(BookingCreateDto $booking, int $participantIndex): float
{
// For now, return 0.0 as placeholder - this will be enhanced
// when we integrate with the existing pricing calculation system
return 0.0;
// Use the price calculator to get the participant's individual price excluding insurance
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
}
/**
@@ -222,4 +319,24 @@ class InsuranceMatchingService
{
return $startDate->diff($endDate)->days;
}
/**
* Filters insurances by type (subType and familyInsurance combination).
*
* This method groups insurances of the same type together for reassignment or batch assignment.
* Insurance type is defined as the combination of subType (RRV, PAK, OHN) and familyInsurance flag.
*
* @param array<Insurance> $insurances All available insurances to filter
* @param string|null $subType The insurance subType to match (e.g., 'RRV', 'PAK')
* @param bool $familyInsurance Whether to match family or individual insurances
*
* @return array<Insurance> Filtered insurances of the same type
*/
private function filterInsurancesByType(array $insurances, ?string $subType, bool $familyInsurance): array
{
return array_filter(
$insurances,
fn (Insurance $insurance) => $insurance->subType === $subType && $insurance->familyInsurance === $familyInsurance
);
}
}
+47
View File
@@ -10,6 +10,7 @@ use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException;
@@ -36,6 +37,7 @@ class TravelDataService
private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -102,6 +104,11 @@ class TravelDataService
public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
{
try {
$this->logger->debug('Loading travel data from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
$travel = $this->travelLoader->loadById($dateId, $hotelId);
$this->enrichTravelData($travel);
@@ -151,6 +158,11 @@ class TravelDataService
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
{
try {
$this->logger->debug('Loading travel data from API', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
// Map dateId to productId for API call
$productId = $this->mapDateIdToProductId($dateId);
if (null === $productId) {
@@ -581,8 +593,13 @@ class TravelDataService
private function enrichTravelData(Travel $travel): void
{
try {
$this->logger->debug('Enriching travel data', [
'travelId' => $travel->id,
]);
$this->pickupLoader->patchPickupsDetails($travel);
$this->hotelLoader->patchHotelDetails($travel);
$this->patchInsuranceData($travel);
} catch (\Exception $e) {
$this->logger->warning('Failed to enrich travel data', [
'travelId' => $travel->id,
@@ -590,4 +607,34 @@ class TravelDataService
]);
}
}
/**
* Adds insurance data to travel object.
*
* Loads all available insurances and adds them to the travel object.
* This ensures insurance options are available for booking.
*
* @param Travel $travel The travel object to enrich with insurance data
*/
private function patchInsuranceData(Travel $travel): void
{
try {
$this->logger->debug('Loading insurance data', [
'travelId' => $travel->id,
]);
$insurances = $this->insuranceLoader->loadAll();
$travel->insurances = array_values($insurances);
$this->logger->debug('Insurance data added to travel', [
'travelId' => $travel->id,
'insuranceCount' => count($insurances),
]);
} catch (\Exception $e) {
$this->logger->warning('Failed to load insurance data', [
'travelId' => $travel->id,
'error' => $e->getMessage(),
]);
}
}
}