diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 29c191bf..9145f6d6 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -318,6 +318,8 @@ returns it in one page. "city": "Frankfurt", "street": "Flughafen (P36 zwischen Terminal 1 und 2)", "price": 5.9, + "returnLeg": true, + "returnPrice": 5.9, "datetime": "2026-12-03T23:30:00" } ], @@ -389,7 +391,9 @@ such as for arriving by car. `GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states -none. It is not levelled against the return leg. An id absent from +none. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and +`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled +against the other. A point served on the return leg only is not emitted. An id absent from `GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id of `0` is emitted as-is but not counted. diff --git a/docs/chatbot-mcp.md b/docs/chatbot-mcp.md index 23796b0d..1bd5f0eb 100644 --- a/docs/chatbot-mcp.md +++ b/docs/chatbot-mcp.md @@ -58,6 +58,10 @@ Trip rules (`App\Chatbot\TripCatalog`): - `search_trips` only offers products that TYPO3 lists in its own website search (`searchable`) and does not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still answers when someone names their code. +- Pickup surcharges are per person and per leg, so each pickup carries `surchargeOutbound`, `surchargeReturn` and + their sum `surchargeRoundTrip`. A single figure was read by the model as the whole price. A leg listed without a + price is `0`, because BusPro bills only priced legs; `surchargeReturn` is absent only where the point is not + served on the way back. - The export's `guide` (name, phone) and all BusPro ids are never output. `search_trips` mirrors the search page (`/skireisen/suche/`), whose data is TYPO3's import of our own diff --git a/src/Chatbot/Mcp/Tool/GetTripTool.php b/src/Chatbot/Mcp/Tool/GetTripTool.php index 1cb8565f..6f6219fb 100644 --- a/src/Chatbot/Mcp/Tool/GetTripTool.php +++ b/src/Chatbot/Mcp/Tool/GetTripTool.php @@ -14,7 +14,7 @@ use Mcp\Schema\ToolAnnotations; #[McpTool( name: 'get_trip', - description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding.', + description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Pickup surcharges are per person and per direction: surchargeOutbound for the way there, surchargeReturn for the way back, surchargeRoundTrip for both; quote the round trip unless the user travels one way only. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding.', annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false), )] final class GetTripTool diff --git a/src/Chatbot/TripCatalog.php b/src/Chatbot/TripCatalog.php index 5c9d89b3..3bd9d497 100644 --- a/src/Chatbot/TripCatalog.php +++ b/src/Chatbot/TripCatalog.php @@ -585,6 +585,11 @@ class TripCatalog } /** + * BusPro prices a boarding point per leg, so a customer travelling both ways pays the + * surcharge twice. Both legs and their sum are spelled out, because a single figure + * reads as the whole price. A leg listed without a price is billed nothing, so it is 0; + * the return is absent only where the point is not served on the way back. + * * @param array $row * * @return list> @@ -598,12 +603,17 @@ class TripCatalog continue; } + $outbound = (float) ($pickup['price'] ?? 0); + $return = ($pickup['returnLeg'] ?? false) ? (float) ($pickup['returnPrice'] ?? 0) : null; + $pickups[] = array_filter([ 'city' => $pickup['city'], 'place' => $pickup['street'] ?? null, // A clock reading without an offset, as BusPro states it. 'departure' => is_string($pickup['datetime'] ?? null) ? str_replace('T', ' ', substr($pickup['datetime'], 0, 16)) : null, - 'surcharge' => $pickup['price'] ?? null, + 'surchargeOutbound' => $outbound, + 'surchargeReturn' => $return, + 'surchargeRoundTrip' => null !== $return ? round($outbound + $return, 2) : null, ], static fn (mixed $value): bool => null !== $value); } diff --git a/src/Service/ProductExportBuilder.php b/src/Service/ProductExportBuilder.php index afa3550d..ad5228c0 100644 --- a/src/Service/ProductExportBuilder.php +++ b/src/Service/ProductExportBuilder.php @@ -188,7 +188,8 @@ class ProductExportBuilder } /** - * Outbound boarding points joined against zustiege.xml, in source order. + * Outbound boarding points joined against zustiege.xml, in source order, each with the + * same point's return-leg entry. A point listed on the return leg only is not emitted. * * getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the * next day. Entries with id 0 are kept: the consumer drops them itself. @@ -206,6 +207,7 @@ class ProductExportBuilder { $resolved = []; $clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC')); + $returnLegs = $this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege_rueck/zustieg_rueck'), null, false); foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, true) as $pickup) { $pickupId = (int) $pickup->id; @@ -221,6 +223,8 @@ class ProductExportBuilder 'city' => $details?->city, 'street' => $details?->street, 'price' => $pickup->price, + 'returnLeg' => isset($returnLegs[$pickupId]), + 'returnPrice' => ($returnLegs[$pickupId] ?? null)?->price, 'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'), ]; } diff --git a/tests/Chatbot/TripCatalogTest.php b/tests/Chatbot/TripCatalogTest.php index 1f00ecde..a30ecdf2 100644 --- a/tests/Chatbot/TripCatalogTest.php +++ b/tests/Chatbot/TripCatalogTest.php @@ -127,10 +127,26 @@ class TripCatalogTest extends TestCase self::assertCount(1, $trip['dates']); self::assertSame('Hotel Eins', $trip['dates'][0]['hotels'][0]['hotel']); self::assertSame([['room' => 'Zimmer', 'board' => 'HP', 'nights' => 7, 'price' => 450, 'availability' => 'frei']], $trip['dates'][0]['hotels'][0]['rooms']); - self::assertSame([['city' => 'Frankfurt', 'place' => 'Flughafen', 'departure' => '2026-12-04 23:30', 'surcharge' => 5.9]], $trip['dates'][0]['pickups']); + self::assertSame([['city' => 'Frankfurt', 'place' => 'Flughafen', 'departure' => '2026-12-04 23:30', 'surchargeOutbound' => 5.9, 'surchargeReturn' => 5.9, 'surchargeRoundTrip' => 11.8]], $trip['dates'][0]['pickups']); self::assertArrayNotHasKey('guide', $trip['dates'][0]); } + public function testPickupSurchargesArePerLegAndAnUnpricedLegIsFree(): void + { + $row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]); + $row['pickups'] = [ + ['busProId' => 18, 'code' => 'ESS', 'city' => 'Essen', 'street' => 'Hbf', 'price' => 17.9, 'returnLeg' => true, 'returnPrice' => null, 'datetime' => null], + ['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => null, 'returnLeg' => false, 'returnPrice' => null, 'datetime' => null], + ]; + + $pickups = $this->catalog([$row])->get('VTH')['dates'][0]['pickups'] ?? null; + + self::assertSame([ + ['city' => 'Essen', 'place' => 'Hbf', 'surchargeOutbound' => 17.9, 'surchargeReturn' => 0.0, 'surchargeRoundTrip' => 17.9], + ['city' => 'Frankfurt', 'place' => 'Flughafen', 'surchargeOutbound' => 0.0], + ], $pickups, 'BusPro bills only priced legs; no return leg, no return surcharge'); + } + public function testProductsHiddenFromTheWebsiteSearchAreNotOfferedButCanBeNamed(): void { $rows = [$this->row('GRP', 2, '2026-12-05', 'HOT1', [$this->room(300)])]; @@ -225,8 +241,8 @@ class TripCatalogTest extends TestCase ], 'rooms' => $rooms, 'pickups' => [ - ['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => 5.9, 'datetime' => $dateFrom->modify('-1 day')->format('Y-m-d').'T23:30:00'], - ['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'datetime' => null], + ['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => 5.9, 'returnLeg' => true, 'returnPrice' => 5.9, 'datetime' => $dateFrom->modify('-1 day')->format('Y-m-d').'T23:30:00'], + ['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'returnLeg' => false, 'returnPrice' => null, 'datetime' => null], ], 'guide' => ['name' => 'Anna', 'phone' => '0170'], ]; diff --git a/tests/Resources/product_export_data.xml b/tests/Resources/product_export_data.xml index 64f1eff4..3f00699b 100644 --- a/tests/Resources/product_export_data.xml +++ b/tests/Resources/product_export_data.xml @@ -88,6 +88,8 @@ + + diff --git a/tests/Service/ProductExportBuilderTest.php b/tests/Service/ProductExportBuilderTest.php index db0baf0e..b5c26bda 100644 --- a/tests/Service/ProductExportBuilderTest.php +++ b/tests/Service/ProductExportBuilderTest.php @@ -159,6 +159,8 @@ class ProductExportBuilderTest extends TestCase 'city' => 'Stadt 4', 'street' => 'Strasse 4', 'price' => 5.9, + 'returnLeg' => false, + 'returnPrice' => null, 'datetime' => '2026-12-03T23:30:00', ], $pickups[0]); self::assertNull($pickups[1]['price'], 'an absent price is null'); @@ -167,6 +169,15 @@ class ProductExportBuilderTest extends TestCase self::assertNull($pickups[2]['code']); } + public function testJoinsEachPickupWithItsReturnLegVerbatim(): void + { + $pickups = $this->rowFor('900-222931')['pickups']; + + self::assertSame([false, true, true, false], array_column($pickups, 'returnLeg')); + self::assertSame(3.0, $pickups[1]['returnPrice']); + self::assertNull($pickups[2]['returnPrice'], 'listed on the return leg without a price'); + } + public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void { // DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.