diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php index 8c1eec38..f5234181 100644 --- a/src/BusProNet/XmlParser/TravelParser.php +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -87,8 +87,8 @@ class TravelParser extends AbstractParser $travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true); $travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false); - // Merge drop-off prices into pickups for split pricing support - $this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs); + // A boarding point costs the same in both directions + $this->levelBoardingPointPrices($travel->pickups, $travel->dropOffs); $travel->guide = $this->getGuide($node); @@ -453,22 +453,38 @@ class TravelParser extends AbstractParser } /** - * Merges drop-off prices into pickups for split pricing support. + * Levels the price of a boarding point across both travel directions. * - * For each pickup, finds the matching drop-off by ID and copies - * the inbound price to the pickup's priceInbound property. This enables - * split pricing calculations where costs are distributed between outbound - * and inbound transportation. + * A boarding point is either free or costs the same in both directions: + * `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so + * BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An + * asymmetric pair is therefore always an export defect — on some products a + * `` carries no `preis` although the engine still charges the return + * leg — and the stated price is the one that applies to both legs. + * + * Where both directions state a price they agree in practice; the outbound one wins + * to keep the outcome deterministic. Points without a counterpart are left as they are. * * @param array $pickups Pickups indexed by ID (modified in place) - * @param array $dropOffs Drop-offs indexed by ID + * @param array $dropOffs Drop-offs indexed by ID (modified in place) */ - private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void + private function levelBoardingPointPrices(array &$pickups, array $dropOffs): void { - foreach ($pickups as $pickupId => $pickup) { - if (isset($dropOffs[$pickupId])) { - $pickup->priceInbound = $dropOffs[$pickupId]->priceInbound; + foreach ($dropOffs as $pickupId => $dropOff) { + if (false === isset($pickups[$pickupId])) { + continue; } + + $pickup = $pickups[$pickupId]; + $price = $pickup->price ?? $dropOff->price; + + $pickup->price = $price; + $pickup->priceOutbound = $price; + $pickup->priceInbound = $price; + + $dropOff->price = $price; + $dropOff->priceOutbound = $price; + $dropOff->priceInbound = $price; } } diff --git a/src/Service/BookingPricingAssembler.php b/src/Service/BookingPricingAssembler.php index 2304215e..fdcbf3bb 100644 --- a/src/Service/BookingPricingAssembler.php +++ b/src/Service/BookingPricingAssembler.php @@ -8,6 +8,7 @@ use App\BusProNet\Constants; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Service; +use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -161,7 +162,9 @@ class BookingPricingAssembler } $hasOutboundBus = null !== $participant->transportationOutbound - && 'BUS' === $participant->transportationOutbound->subType; + && DirectionMapper::isBus($participant->transportationOutbound->subType); + $hasInboundBus = null !== $participant->transportationInbound + && DirectionMapper::isBus($participant->transportationInbound->subType); if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { if ($participant->pickup->price < 0) { @@ -171,7 +174,7 @@ class BookingPricingAssembler } } - if (null !== $participant->dropOff && null !== $participant->dropOff->price) { + if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) { if ($participant->dropOff->price < 0) { $participantTransportationDiscountCost += $participant->dropOff->price; } else { diff --git a/src/Service/ParticipantPricingCalculator.php b/src/Service/ParticipantPricingCalculator.php index 1e70cb02..9ba63411 100644 --- a/src/Service/ParticipantPricingCalculator.php +++ b/src/Service/ParticipantPricingCalculator.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service; +use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -193,18 +194,17 @@ class ParticipantPricingCalculator } } - // Pickup pricing - only charged when outbound transportation is BUS - // This matches current BusPro behavior where pickup price is only applied for outbound bus - // Note: priceOutbound/priceInbound are populated for future split pricing support when BusPro is updated + // Boarding surcharges are charged per leg, at the same rate in both directions $hasOutboundBus = null !== $participant->transportationOutbound - && 'BUS' === $participant->transportationOutbound->subType; + && DirectionMapper::isBus($participant->transportationOutbound->subType); + $hasInboundBus = null !== $participant->transportationInbound + && DirectionMapper::isBus($participant->transportationInbound->subType); if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { $serviceTotal += $participant->pickup->price; } - // Drop-off pricing (charged when inbound transportation is BUS and a drop-off is selected) - if (null !== $participant->dropOff && null !== $participant->dropOff->price) { + if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) { $serviceTotal += $participant->dropOff->price; } diff --git a/tests/BusProNet/XmlParser/TravelParserTest.php b/tests/BusProNet/XmlParser/TravelParserTest.php index b3b734b7..8bc0cabb 100644 --- a/tests/BusProNet/XmlParser/TravelParserTest.php +++ b/tests/BusProNet/XmlParser/TravelParserTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\BusProNet\XmlParser; +use App\BusProNet\Model\Travel; use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlParser\TravelParser; use PHPUnit\Framework\Attributes\DataProvider; @@ -355,6 +356,79 @@ class TravelParserTest extends TestCase $this->assertNull($travel->status); // No status_hin node should result in null } + public function testDropOffsInheritThePickupPriceWhenTheReturnLegStatesNone(): void + { + // Shape of product 701: the return boarding points carry no preis although + // BusPro bills an Abfahrtsortzuschlag for the return leg as well. + $travel = $this->parseTravelWithBoardingPoints( + ' + ', + ' + ' + ); + + $this->assertSame(17.9, $travel->dropOffs[18]->price); + $this->assertSame(17.9, $travel->dropOffs[18]->priceInbound); + $this->assertSame(17.9, $travel->pickups[18]->priceInbound); + + // A point that is free outbound stays free on the return leg + $this->assertNull($travel->dropOffs[27]->price); + $this->assertNull($travel->pickups[27]->priceInbound); + } + + public function testPickupInheritsTheDropOffPriceWhenTheOutboundLegStatesNone(): void + { + // The mirror image of the product 701 defect: an asymmetric pair is a defect + // whichever leg is missing, so the stated price applies to both. + $travel = $this->parseTravelWithBoardingPoints( + '', + '' + ); + + $this->assertSame(17.9, $travel->pickups[18]->price); + $this->assertSame(17.9, $travel->pickups[18]->priceOutbound); + $this->assertSame(17.9, $travel->dropOffs[18]->price); + } + + public function testConflictingBoardingPricesResolveToTheOutboundOne(): void + { + $travel = $this->parseTravelWithBoardingPoints( + '', + '' + ); + + $this->assertSame(17.9, $travel->pickups[18]->price); + $this->assertSame(17.9, $travel->dropOffs[18]->price); + } + + public function testDropOffWithoutAPickupCounterpartKeepsItsOwnPrice(): void + { + $travel = $this->parseTravelWithBoardingPoints( + '', + '' + ); + + $this->assertSame(5.9, $travel->dropOffs[99]->price); + } + + private function parseTravelWithBoardingPoints(string $pickups, string $dropOffs): Travel + { + $xmlContent = ' + + + + Test Travel + '.$pickups.' + '.$dropOffs.' + + +'; + + $crawler = XmlCrawlerFactory::create($xmlContent); + + return $this->parser->parse($crawler->filterXPath('//reise/termin')->first()); + } + public function testGetPickupsAdjustsOvernightTimes(): void { $xml = ' diff --git a/tests/Service/BookingPricingAssemblerTest.php b/tests/Service/BookingPricingAssemblerTest.php index e2f2a654..39b3bca8 100644 --- a/tests/Service/BookingPricingAssemblerTest.php +++ b/tests/Service/BookingPricingAssemblerTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\Service; use App\BusProNet\Model\Insurance; +use App\BusProNet\Model\Pickup; +use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -311,6 +313,94 @@ class BookingPricingAssemblerTest extends TestCase 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'); + } + + 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();