diff --git a/src/Service/BookingPricingAssembler.php b/src/Service/BookingPricingAssembler.php index 53b3459f..44dc98a6 100644 --- a/src/Service/BookingPricingAssembler.php +++ b/src/Service/BookingPricingAssembler.php @@ -21,6 +21,12 @@ use App\Form\Model\ParticipantDto; */ class BookingPricingAssembler { + /** + * BusPro's label for the per-leg boarding surcharge, suffixed " (Hinfahrt)" or " (Rückfahrt)". + * A booking response's `` carries no type attribute, so the label is all there is. + */ + private const BOARDING_SURCHARGE_LABEL = 'Abfahrtsortzuschlag'; + public function __construct( private readonly RoomPricingCalculator $roomPricingCalculator, private readonly ParticipantEligibilityChecker $participantEligibilityChecker, @@ -108,6 +114,12 @@ class BookingPricingAssembler continue; } + // Listed per leg among the transportation services, from the participants' current + // selection rather than the stored booking, so BusPro's copy would count it twice + if (str_starts_with($surcharge->label ?? '', self::BOARDING_SURCHARGE_LABEL)) { + continue; + } + $surchargePricing[] = [ 'label' => $surcharge->label ?? 'unbekannt', 'participantCount' => count($surcharge->mapping), @@ -122,8 +134,10 @@ class BookingPricingAssembler * Aggregates all transportation-related services and pricing into separate line items. * * Creates separate entries for: - * - Beförderung: Sum of all positive pickup prices and base transportation costs - * - Beförderung - Rabatt: Sum of all negative transportation prices (discounts) + * - Beförderung: Sum of all positive base transportation costs + * - Beförderung - Rabatt: Sum of all negative base transportation prices (discounts) + * - Abfahrtsortzuschlag (Hinfahrt) / (Rückfahrt): Boarding surcharges per leg, labelled as + * BusPro bills them, so the breakdown reads like the invoice * - Parkplatz: Sum of all parking service prices * * Only includes transportation costs from eligible participants. @@ -134,36 +148,25 @@ class BookingPricingAssembler { $participants = $bookingDto->getParticipants(); - $transportationPositiveTotal = 0.0; - $transportationDiscountTotal = 0.0; - $parkingTotal = 0.0; - - $transportationParticipants = 0; - $discountParticipants = 0; - $parkingParticipants = 0; + $totals = [ + 'transportation_positive' => 0.0, + 'transportation_discount' => 0.0, + 'transportation_pickup' => 0.0, + 'transportation_drop_off' => 0.0, + 'transportation_parking' => 0.0, + ]; + $participantCounts = array_fill_keys(array_keys($totals), 0); foreach ($participants as $participantIndex => $participant) { if (false === $this->isParticipantBilled($bookingDto, $participantIndex, $participant)) { continue; } - $participantTransportationPositiveCost = 0.0; - $participantTransportationDiscountCost = 0.0; - $participantParkingCost = 0.0; + $participantCosts = array_fill_keys(array_keys($totals), 0.0); - if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { - if ($participant->transportationOutbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationOutbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationOutbound->price; - } - } - - if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { - if ($participant->transportationInbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationInbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationInbound->price; + foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $transportation) { + if (null !== $transportation && null !== $transportation->price) { + $participantCosts[$transportation->price < 0 ? 'transportation_discount' : 'transportation_positive'] += $transportation->price; } } @@ -173,71 +176,47 @@ class BookingPricingAssembler && DirectionMapper::isBus($participant->transportationInbound->subType); if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { - if ($participant->pickup->price < 0) { - $participantTransportationDiscountCost += $participant->pickup->price; - } else { - $participantTransportationPositiveCost += $participant->pickup->price; - } + $participantCosts['transportation_pickup'] += $participant->pickup->price; } if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) { - if ($participant->dropOff->price < 0) { - $participantTransportationDiscountCost += $participant->dropOff->price; - } else { - $participantTransportationPositiveCost += $participant->dropOff->price; - } + $participantCosts['transportation_drop_off'] += $participant->dropOff->price; } if (null !== $participant->parkingService && null !== $participant->parkingService->price) { - $participantParkingCost += $participant->parkingService->price; + $participantCosts['transportation_parking'] += $participant->parkingService->price; } - if ($participantTransportationPositiveCost > 0) { - $transportationPositiveTotal += $participantTransportationPositiveCost; - ++$transportationParticipants; - } - if ($participantTransportationDiscountCost < 0) { - $transportationDiscountTotal += $participantTransportationDiscountCost; - ++$discountParticipants; - } - if ($participantParkingCost > 0) { - $parkingTotal += $participantParkingCost; - ++$parkingParticipants; + foreach ($participantCosts as $key => $cost) { + if (0.0 !== round($cost, 2)) { + $totals[$key] += $cost; + ++$participantCounts[$key]; + } } } + $definitions = [ + 'transportation_positive' => ['Beförderung', Constants::GROUP_TRANSPORTATION], + 'transportation_discount' => ['Beförderung - Rabatt', Constants::GROUP_TRANSPORTATION.'_discount'], + 'transportation_pickup' => [self::BOARDING_SURCHARGE_LABEL.' (Hinfahrt)', Constants::GROUP_TRANSPORTATION], + 'transportation_drop_off' => [self::BOARDING_SURCHARGE_LABEL.' (Rückfahrt)', Constants::GROUP_TRANSPORTATION], + 'transportation_parking' => [Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], Constants::GROUP_TRANSPORTATION], + ]; + $transportationItems = []; - if ($transportationPositiveTotal > 0) { - $transportationItems['transportation_positive'] = [ - 'serviceId' => 'transportation_positive', - 'label' => 'Beförderung', - 'unitPrice' => null, - 'participantCount' => $transportationParticipants, - 'totalPrice' => $transportationPositiveTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, - ]; - } + foreach ($definitions as $key => [$label, $subType]) { + if (0 === $participantCounts[$key]) { + continue; + } - if ($transportationDiscountTotal < 0) { - $transportationItems['transportation_discount'] = [ - 'serviceId' => 'transportation_discount', - 'label' => 'Beförderung - Rabatt', + $transportationItems[$key] = [ + 'serviceId' => $key, + 'label' => $label, 'unitPrice' => null, - 'participantCount' => $discountParticipants, - 'totalPrice' => $transportationDiscountTotal, - 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', - ]; - } - - if ($parkingTotal > 0) { - $transportationItems['transportation_parking'] = [ - 'serviceId' => 'transportation_parking', - 'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], - 'unitPrice' => null, - 'participantCount' => $parkingParticipants, - 'totalPrice' => $parkingTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, + 'participantCount' => $participantCounts[$key], + 'totalPrice' => $totals[$key], + 'subType' => $subType, ]; } diff --git a/tests/Service/BookingPricingAssemblerTest.php b/tests/Service/BookingPricingAssemblerTest.php index af5be498..5e78b5f5 100644 --- a/tests/Service/BookingPricingAssemblerTest.php +++ b/tests/Service/BookingPricingAssemblerTest.php @@ -328,6 +328,25 @@ class BookingPricingAssemblerTest extends TestCase $this->assertEqualsWithDelta(71.6, $group['groupTotal'], 0.001, 'Both legs are charged at €17,90 per participant'); } + public function testBoardingSurchargeIsListedPerLegAsBusProBillsIt(): void + { + $bookingDto = $this->createBusBookingDto('BUS', 'BUS'); + + $group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung'); + + $this->assertSame([ + ['label' => 'Abfahrtsortzuschlag (Hinfahrt)', 'participantCount' => 2, 'totalPrice' => 35.8], + ['label' => 'Abfahrtsortzuschlag (Rückfahrt)', 'participantCount' => 2, 'totalPrice' => 35.8], + ], array_map( + static fn (array $line): array => [ + 'label' => $line['label'], + 'participantCount' => $line['participantCount'], + 'totalPrice' => round($line['totalPrice'], 2), + ], + $group['services'] + ), 'A free bus leg adds no Beförderung line of its own'); + } + public function testBoardingSurchargeSkipsTheReturnLegWhenItIsNotByBus(): void { $bookingDto = $this->createBusBookingDto('BUS', 'PKW'); @@ -409,6 +428,37 @@ class BookingPricingAssemblerTest extends TestCase ], $surchargePricing); } + public function testSurchargePricingOmitsBoardingSurchargesListedAmongTheServices(): void + { + // Booking 98398: BusPro reports the boarding surcharge as untyped lines, + // which the transportation group already lists per leg. + $booking = new Booking(); + $booking->surcharges = [ + $this->createSurcharge('Abfahrtsortzuschlag (Hinfahrt)', 141.9, range(0, 10)), + $this->createSurcharge('Abfahrtsortzuschlag (Rückfahrt)', 141.9, range(0, 10)), + $this->createSurcharge('Stornokosten', 120.0, [3]), + ]; + + $this->assertSame([ + ['label' => 'Stornokosten', 'participantCount' => 1, 'totalPrice' => 120.0], + ], $this->assembler->calculateSurchargePricing($booking)); + } + + public function testPricingBreakdownCountsTheBoardingSurchargeOnceInEditMode(): void + { + $bookingDto = $this->createBusBookingDto('BUS', 'BUS'); + $bookingDto->booking = new Booking(); + $bookingDto->booking->surcharges = [ + $this->createSurcharge('Abfahrtsortzuschlag (Hinfahrt)', 35.8, [0, 1]), + $this->createSurcharge('Abfahrtsortzuschlag (Rückfahrt)', 35.8, [0, 1]), + ]; + + $breakdown = $this->assembler->getPricingBreakdown($bookingDto); + + $this->assertArrayNotHasKey('surcharges', $breakdown); + $this->assertEqualsWithDelta(71.6, $breakdown['grandTotal'], 0.001); + } + public function testPricingBreakdownOmitsSurchargesWhenAllAreZero(): void { $travel = new Travel();