fix: always adopt outbound pickup price for inbound when set

This commit is contained in:
fromme committed 2026-09-28 16:33:36 +02:00
1 parent 29f295abf6
commit 0c6126010f
5 files changed
+203 -20

No files matched your search

+28 -12
View File
@@ -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
* `<zustieg_rueck>` 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<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
* @param array<int, Pickup> $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;
}
}
+5 -2
View File
@@ -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 {
+6 -6
View File
@@ -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;
}
@@ -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(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />
<zustieg idbuspro="27" zeit="03:45" />',
'<zustieg_rueck idbuspro="18" />
<zustieg_rueck idbuspro="27" />'
);
$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(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" />',
'<zustieg_rueck idbuspro="18" preis="17,90" />'
);
$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(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="18" preis="9,90" />'
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
}
public function testDropOffWithoutAPickupCounterpartKeepsItsOwnPrice(): void
{
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="99" preis="5,90" />'
);
$this->assertSame(5.9, $travel->dropOffs[99]->price);
}
private function parseTravelWithBoardingPoints(string $pickups, string $dropOffs): Travel
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise id="1" idbuspro="701" code="MVKMP" erstellt_am="28.09.2026 12:29:00">
<termin id="1" idbuspro="12939" idprodukt="701" termin="13.12.2026" bis="18.12.2026" code="MVKMP131226" reiseart="F">
<text>Test Travel</text>
<zustiege>'.$pickups.'</zustiege>
<zustiege_rueck>'.$dropOffs.'</zustiege_rueck>
</termin>
</reise>
</reisen>';
$crawler = XmlCrawlerFactory::create($xmlContent);
return $this->parser->parse($crawler->filterXPath('//reise/termin')->first());
}
public function testGetPickupsAdjustsOvernightTimes(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?>
@@ -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();