fix: give chatbot pickups per-leg surcharges including the return leg
This commit is contained in:
1 parent
ac5ca7b18b
commit
3cb1548f2f
8 files changed
+58
-7
No files matched your search
@@ -318,6 +318,8 @@ returns it in one page.
|
|||||||
"city": "Frankfurt",
|
"city": "Frankfurt",
|
||||||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||||
"price": 5.9,
|
"price": 5.9,
|
||||||
|
"returnLeg": true,
|
||||||
|
"returnPrice": 5.9,
|
||||||
"datetime": "2026-12-03T23:30:00"
|
"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
|
`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
|
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
|
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
|
`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.
|
of `0` is emitted as-is but not counted.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- `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
|
not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still
|
||||||
answers when someone names their code.
|
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.
|
- 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
|
`search_trips` mirrors the search page (`/skireisen/suche/`), whose data is TYPO3's import of our own
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Mcp\Schema\ToolAnnotations;
|
|||||||
|
|
||||||
#[McpTool(
|
#[McpTool(
|
||||||
name: 'get_trip',
|
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),
|
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
|
||||||
)]
|
)]
|
||||||
final class GetTripTool
|
final class GetTripTool
|
||||||
|
|||||||
@@ -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<string, mixed> $row
|
* @param array<string, mixed> $row
|
||||||
*
|
*
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
@@ -598,12 +603,17 @@ class TripCatalog
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$outbound = (float) ($pickup['price'] ?? 0);
|
||||||
|
$return = ($pickup['returnLeg'] ?? false) ? (float) ($pickup['returnPrice'] ?? 0) : null;
|
||||||
|
|
||||||
$pickups[] = array_filter([
|
$pickups[] = array_filter([
|
||||||
'city' => $pickup['city'],
|
'city' => $pickup['city'],
|
||||||
'place' => $pickup['street'] ?? null,
|
'place' => $pickup['street'] ?? null,
|
||||||
// A clock reading without an offset, as BusPro states it.
|
// 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,
|
'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);
|
], static fn (mixed $value): bool => null !== $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* next day. Entries with id 0 are kept: the consumer drops them itself.
|
||||||
@@ -206,6 +207,7 @@ class ProductExportBuilder
|
|||||||
{
|
{
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
$clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC'));
|
$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) {
|
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, true) as $pickup) {
|
||||||
$pickupId = (int) $pickup->id;
|
$pickupId = (int) $pickup->id;
|
||||||
@@ -221,6 +223,8 @@ class ProductExportBuilder
|
|||||||
'city' => $details?->city,
|
'city' => $details?->city,
|
||||||
'street' => $details?->street,
|
'street' => $details?->street,
|
||||||
'price' => $pickup->price,
|
'price' => $pickup->price,
|
||||||
|
'returnLeg' => isset($returnLegs[$pickupId]),
|
||||||
|
'returnPrice' => ($returnLegs[$pickupId] ?? null)?->price,
|
||||||
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,10 +127,26 @@ class TripCatalogTest extends TestCase
|
|||||||
self::assertCount(1, $trip['dates']);
|
self::assertCount(1, $trip['dates']);
|
||||||
self::assertSame('Hotel Eins', $trip['dates'][0]['hotels'][0]['hotel']);
|
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([['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]);
|
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
|
public function testProductsHiddenFromTheWebsiteSearchAreNotOfferedButCanBeNamed(): void
|
||||||
{
|
{
|
||||||
$rows = [$this->row('GRP', 2, '2026-12-05', 'HOT1', [$this->room(300)])];
|
$rows = [$this->row('GRP', 2, '2026-12-05', 'HOT1', [$this->room(300)])];
|
||||||
@@ -225,8 +241,8 @@ class TripCatalogTest extends TestCase
|
|||||||
],
|
],
|
||||||
'rooms' => $rooms,
|
'rooms' => $rooms,
|
||||||
'pickups' => [
|
'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' => 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, 'datetime' => null],
|
['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'returnLeg' => false, 'returnPrice' => null, 'datetime' => null],
|
||||||
],
|
],
|
||||||
'guide' => ['name' => 'Anna', 'phone' => '0170'],
|
'guide' => ['name' => 'Anna', 'phone' => '0170'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -88,6 +88,8 @@
|
|||||||
<zustiege_rueck>
|
<zustiege_rueck>
|
||||||
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
|
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
|
||||||
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
|
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
|
||||||
|
<!-- Listed without a price: BusPro bills the return leg nothing. -->
|
||||||
|
<zustieg_rueck id="2" idbuspro="55" />
|
||||||
</zustiege_rueck>
|
</zustiege_rueck>
|
||||||
</termin>
|
</termin>
|
||||||
<!-- Unparseable date: its hotel row is dropped and counted. -->
|
<!-- Unparseable date: its hotel row is dropped and counted. -->
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ class ProductExportBuilderTest extends TestCase
|
|||||||
'city' => 'Stadt 4',
|
'city' => 'Stadt 4',
|
||||||
'street' => 'Strasse 4',
|
'street' => 'Strasse 4',
|
||||||
'price' => 5.9,
|
'price' => 5.9,
|
||||||
|
'returnLeg' => false,
|
||||||
|
'returnPrice' => null,
|
||||||
'datetime' => '2026-12-03T23:30:00',
|
'datetime' => '2026-12-03T23:30:00',
|
||||||
], $pickups[0]);
|
], $pickups[0]);
|
||||||
self::assertNull($pickups[1]['price'], 'an absent price is null');
|
self::assertNull($pickups[1]['price'], 'an absent price is null');
|
||||||
@@ -167,6 +169,15 @@ class ProductExportBuilderTest extends TestCase
|
|||||||
self::assertNull($pickups[2]['code']);
|
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
|
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
|
||||||
{
|
{
|
||||||
// DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.
|
// DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.
|
||||||
|
|||||||
Reference in new issue
Block a user