fix: always adopt outbound pickup price for inbound when set
This commit is contained in:
1 parent
29f295abf6
commit
0c6126010f
5 files changed
+203
-20
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user