Files
myep/tests/Service/BookingPricingAssemblerTest.php
T

517 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Surcharge;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\ApplicantInsuranceCascade;
use App\Service\BookingPriceCalculator;
use App\Service\BookingPricingAssembler;
use App\Service\InsuranceManager;
use App\Service\ParticipantEligibilityChecker;
use App\Service\ParticipantPricingCalculator;
use App\Service\RoomPricingCalculator;
use PHPUnit\Framework\TestCase;
class BookingPricingAssemblerTest extends TestCase
{
private BookingPricingAssembler $assembler;
private ParticipantEligibilityChecker $eligibilityChecker;
protected function setUp(): void
{
$this->eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$this->eligibilityChecker->method('isParticipantEligible')->willReturn(true);
$this->assembler = $this->createAssembler($this->eligibilityChecker);
}
public function testServicePricingWithoutBulkInsurance(): void
{
$travel = new Travel();
$travel->insurances = [];
$insurance1 = $this->createInsurance(1, 'Insurance A', 50.0);
$insurance2 = $this->createInsurance(2, 'Insurance B', 75.0);
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insurance1;
$participant1->bulkInsuranceBooking = false;
$participant2 = new ParticipantDto();
$participant2->index = 1;
$participant2->insurance = $insurance2;
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [$participant1, $participant2];
$result = $this->assembler->calculateServicePricing($bookingDto);
$this->assertNotEmpty($result);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items');
}
public function testServicePricingWithBulkInsuranceSamePriceTier(): void
{
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
$insurance->travelPriceFrom = 0.0;
$insurance->travelPriceTo = 10000.0;
$travel = new Travel();
$travel->insurances = [$insurance];
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insurance;
$participant1->bulkInsuranceBooking = true;
$participant2 = new ParticipantDto();
$participant2->index = 1;
$participant2->insurance = null;
$participant3 = new ParticipantDto();
$participant3->index = 2;
$participant3->insurance = null;
$bookingDto = new BookingDto($travel, 3);
$bookingDto->participants = [$participant1, $participant2, $participant3];
// Bulk assignment is materialised into the DTO by the cascade; the assembler then
// simply reports what each participant holds.
$this->createCascade()->apply($bookingDto);
$result = $this->assembler->calculateServicePricing($bookingDto);
// 3× same insurance should aggregate into one line item
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
$this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants');
$this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50');
}
public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void
{
$insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV');
$insuranceTier1->travelPriceFrom = 0.0;
$insuranceTier1->travelPriceTo = 500.0;
$insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV');
$insuranceTier2->travelPriceFrom = 501.0;
$insuranceTier2->travelPriceTo = 1000.0;
$travel = new Travel();
$travel->insurances = [$insuranceTier1, $insuranceTier2];
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insuranceTier2;
$participant1->bulkInsuranceBooking = true;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1];
$result = $this->assembler->calculateServicePricing($bookingDto);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant');
}
public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void
{
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
$travel = new Travel();
$travel->insurances = [$insurance];
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insurance;
$participant1->bulkInsuranceBooking = false;
$participant2 = new ParticipantDto();
$participant2->index = 1;
$participant2->insurance = null;
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [$participant1, $participant2];
$result = $this->assembler->calculateServicePricing($bookingDto);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
$this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50');
}
public function testServicePricingWithIneligibleParticipant(): void
{
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
$travel = new Travel();
$travel->insurances = [$insurance];
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insurance;
$participant1->bulkInsuranceBooking = false;
$participant2 = new ParticipantDto();
$participant2->index = 1;
$participant2->insurance = null;
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [$participant1, $participant2];
$eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$eligibilityChecker->method('isParticipantEligible')
->willReturnCallback(fn ($booking, int $index): bool => 0 === $index);
$result = $this->createAssembler($eligibilityChecker)->calculateServicePricing($bookingDto);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
}
public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void
{
$insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
$travel = new Travel();
$travel->insurances = [$insuranceA];
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$participant1 = new ParticipantDto();
$participant1->index = 0;
$participant1->insurance = $insuranceA;
$participant1->bulkInsuranceBooking = true;
$participant2 = new ParticipantDto();
$participant2->index = 1;
$participant2->insurance = null;
$participant3 = new ParticipantDto();
$participant3->index = 2;
$participant3->insurance = null;
$bookingDto = new BookingDto($travel, 3);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$this->createCascade()->apply($bookingDto);
$result = $this->assembler->calculateServicePricing($bookingDto);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount'));
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
}
/**
* Regression for the production report: an adult and two children each picked their own
* insurance, then the applicant switched to the family insurance that had become
* available. The sidebar kept listing the two children's superseded policies, and the
* grand total stayed inflated by their premiums.
*
* The sidebar reports whatever the DTO holds, so this asserts the end-to-end contract:
* once ApplicantInsuranceCascade has run, only the family policy remains to aggregate.
*/
public function testFamilyInsuranceLeavesOnlyOneInsuranceLineItem(): void
{
$travel = $this->createFamilyTravel();
$familyInsurance = $this->createInsurance(1, 'Familienversicherung', 120.0);
$familyInsurance->familyInsurance = true;
$applicant = $this->createParticipantWithAge(0, '1985-06-15');
$applicant->insurance = $familyInsurance;
$applicant->bulkInsuranceBooking = false;
$childOne = $this->createParticipantWithAge(1, '2016-01-01');
$childOne->insurance = $this->createInsurance(2, 'Insurance A', 50.0);
$childTwo = $this->createParticipantWithAge(2, '2018-01-01');
$childTwo->insurance = $this->createInsurance(3, 'Insurance B', 75.0);
$travel->insurances = [$familyInsurance];
$bookingDto = new BookingDto($travel, 3);
$bookingDto->participants = [$applicant, $childOne, $childTwo];
$this->createCascade()->apply($bookingDto);
$result = $this->assembler->calculateServicePricing($bookingDto);
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
$this->assertCount(
1,
$insuranceGroup['services'],
'Only the family policy may be listed - the children\'s superseded selections must be gone'
);
$lineItem = array_values($insuranceGroup['services'])[0];
$this->assertSame('Familienversicherung', $lineItem['label']);
$this->assertEquals(1, $lineItem['participantCount'], 'Family policy is held once, by the applicant');
$this->assertEqualsWithDelta(120.0, $insuranceGroup['groupTotal'], 0.001, 'Total must not include the children\'s former premiums');
}
// Helpers
private function createCascade(): ApplicantInsuranceCascade
{
$roomPricingCalculator = new RoomPricingCalculator();
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
$insuranceManager = new InsuranceManager();
return new ApplicantInsuranceCascade(
$insuranceManager,
new BookingPriceCalculator(
$roomPricingCalculator,
$this->createAssembler($this->eligibilityChecker),
$participantPricingCalculator
)
);
}
private function createFamilyTravel(): Travel
{
$travel = new Travel();
$travel->id = 1;
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
$travel->dateTo = new \DateTimeImmutable('2026-02-08');
$travel->insurances = [];
return $travel;
}
private function createParticipantWithAge(int $index, string $dateOfBirth): ParticipantDto
{
$participant = new ParticipantDto();
$participant->index = $index;
$participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth);
return $participant;
}
public function testBoardingSurchargeIsChargedForBothBusLegs(): void
{
// Travel 12939: two participants boarding and returning at point 18 (€17,90),
// which BusPro bills as two TRA positions of €35,80.
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertNotNull($group, 'Transportation group should exist');
$this->assertEqualsWithDelta(71.6, $group['groupTotal'], 0.001, 'Both legs are charged at €17,90 per participant');
}
public function testBoardingSurchargeSkipsTheReturnLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'PKW');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the outbound leg is charged');
}
public function testBoardingSurchargeSkipsTheOutboundLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('PKW', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the return leg is charged');
}
public function testBoardingSurchargeTotalMatchesTheSumOfTheParticipantTotals(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$participantCalculator = new ParticipantPricingCalculator(new RoomPricingCalculator());
$participantTotal = array_sum($participantCalculator->calculateAllParticipantIndividualPrices($bookingDto));
$this->assertEqualsWithDelta($group['groupTotal'], $participantTotal, 0.001, 'Summary and per-participant totals must agree');
}
public function testServicePricingExcludesCanceledParticipants(): void
{
$travel = new Travel();
$travel->insurances = [];
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [];
foreach (['F', 'S'] as $index => $status) {
$mandatoryService = new Service();
$mandatoryService->id = 1;
$mandatoryService->label = 'Kurtaxe';
$mandatoryService->subType = Constants::TOKEN_ADDITIONAL;
$mandatoryService->mandatory = true;
$mandatoryService->price = 12.5;
$participant = new ParticipantDto();
$participant->index = $index;
$participant->status = $status;
$participant->additionalServices = [$mandatoryService];
$participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', 'BUS');
$participant->pickup = $this->createBoardingPoint(18, 17.9);
$bookingDto->participants[$index] = $participant;
}
$servicePricing = $this->assembler->calculateServicePricing($bookingDto);
$this->assertCount(2, $servicePricing);
foreach ($servicePricing as $group) {
$this->assertSame(1, $group['services'][0]['participantCount'], $group['groupName'].' counts only the active participant');
}
$this->assertEqualsWithDelta(12.5 + 17.9, array_sum(array_column($servicePricing, 'groupTotal')), 0.001);
}
public function testSurchargePricingOmitsZeroAmountSurcharges(): void
{
$booking = new Booking();
$booking->surcharges = [
$this->createSurcharge('Stornokosten', 0.0, [1]),
$this->createSurcharge('unbekannt', -0.0, [0]),
$this->createSurcharge('Teamer', -53.2, [0, 1]),
];
$surchargePricing = $this->assembler->calculateSurchargePricing($booking);
$this->assertSame([
['label' => 'Teamer', 'participantCount' => 2, 'totalPrice' => -53.2],
], $surchargePricing);
}
public function testPricingBreakdownOmitsSurchargesWhenAllAreZero(): void
{
$travel = new Travel();
$travel->insurances = [];
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [];
$bookingDto->booking = new Booking();
$bookingDto->booking->surcharges = [$this->createSurcharge('Stornokosten', 0.0, [0])];
$breakdown = $this->assembler->getPricingBreakdown($bookingDto);
$this->assertArrayNotHasKey('surcharges', $breakdown);
}
/** @param list<int> $mapping */
private function createSurcharge(string $label, float $totalPrice, array $mapping): Surcharge
{
$surcharge = new Surcharge();
$surcharge->label = $label;
$surcharge->totalPrice = $totalPrice;
$surcharge->mapping = $mapping;
return $surcharge;
}
private function createBusBookingDto(string $outboundSubType, string $inboundSubType): BookingDto
{
$travel = new Travel();
$travel->insurances = [];
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [];
foreach ([0, 1] as $index) {
$participant = new ParticipantDto();
$participant->index = $index;
$participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', $outboundSubType);
$participant->transportationInbound = $this->createTransportationService(203235, 'Bus-Rückfahrt', $inboundSubType);
$participant->pickup = $this->createBoardingPoint(18, 17.9);
$participant->dropOff = $this->createBoardingPoint(18, 17.9);
$bookingDto->participants[] = $participant;
}
return $bookingDto;
}
private function createTransportationService(int $id, string $label, string $subType): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = $subType;
$service->price = 0.0;
return $service;
}
private function createBoardingPoint(int $id, float $price): Pickup
{
$pickup = new Pickup();
$pickup->id = $id;
$pickup->city = 'Teststadt';
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
return $pickup;
}
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
{
$roomPricingCalculator = new RoomPricingCalculator();
return new BookingPricingAssembler(
$roomPricingCalculator,
$eligibilityChecker,
);
}
private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV'): Insurance
{
$insurance = new Insurance();
$insurance->id = (string) $id;
$insurance->label = $label;
$insurance->price = $price;
$insurance->subType = $subType;
$insurance->package = false;
$insurance->complementary = false;
return $insurance;
}
private function findServiceGroup(array $groups, string $groupName): ?array
{
foreach ($groups as $group) {
if ($group['groupName'] === $groupName) {
return $group;
}
}
return null;
}
}