feat: turn search_trips into the website's travel search
This commit is contained in:
1 parent
bfdedefae3
commit
4ab2714bde
12 files changed
+693
-151
No files matched your search
@@ -6,7 +6,10 @@ namespace App\Chatbot\Mcp\Tool;
|
||||
|
||||
use App\Chatbot\Mcp\Sites;
|
||||
use App\Chatbot\Mcp\ToolCallRunner;
|
||||
use App\Chatbot\TravelPeriods;
|
||||
use App\Chatbot\TripCatalog;
|
||||
use App\Enum\HotelType;
|
||||
use App\Enum\RoomType;
|
||||
use App\Model\Chatbot\TripCriteria;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
@@ -16,13 +19,14 @@ use Mcp\Schema\ToolAnnotations;
|
||||
|
||||
#[McpTool(
|
||||
name: 'search_trips',
|
||||
description: 'Find bookable trips by structured criteria: travel period, destination, trip type, price, group size. Use for any question about which trips exist, when, where, or how much. Returns trips with their next dates and lowest prices per person in EUR. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
|
||||
description: 'The website\'s travel search: find bookable trips by travel period, destination, concept, price, group size, accommodation, board, room type, duration and bus. Use for any question about which trips exist, when, where or how much. Returns one result per trip, hotel and travel date with the "ab" price per person in EUR, nights, what is included, room types and a link; get_trip gives the details of one. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
|
||||
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
|
||||
)]
|
||||
final class SearchTripsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TripCatalog $catalog,
|
||||
private readonly TravelPeriods $periods,
|
||||
private readonly ToolCallRunner $runner,
|
||||
) {
|
||||
}
|
||||
@@ -30,34 +34,47 @@ final class SearchTripsTool
|
||||
public function __invoke(
|
||||
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
|
||||
?string $query = null,
|
||||
#[Schema(description: 'Start of the travel period, YYYY-MM-DD. Trips overlapping the period match.', format: 'date')]
|
||||
#[Schema(description: 'A named travel period of the website search: Vorsaison, um Weihnachten, Silvester, 1 Januarwoche, Ostern, Nachsaison. Use instead of dateFrom/dateTo.')]
|
||||
?string $period = null,
|
||||
#[Schema(description: 'Earliest start date, YYYY-MM-DD. Trips must start on or after it.', format: 'date')]
|
||||
?string $dateFrom = null,
|
||||
#[Schema(description: 'End of the travel period, YYYY-MM-DD.', format: 'date')]
|
||||
#[Schema(description: 'Latest end date, YYYY-MM-DD. Trips must end on or before it.', format: 'date')]
|
||||
?string $dateTo = null,
|
||||
#[Schema(description: 'Country name, e.g. "Frankreich".')]
|
||||
?string $country = null,
|
||||
#[Schema(description: 'Region, ski area or town name, e.g. "Les 3 Vallées".')]
|
||||
?string $region = null,
|
||||
#[Schema(description: 'Trip concept code or name, e.g. ski, party, group.')]
|
||||
#[Schema(description: 'Trip concept code or name, e.g. Sportclub, Ski-Kurztrips, Semesterende-Skireisen.')]
|
||||
?string $concept = null,
|
||||
#[Schema(description: 'Maximum price per person in EUR.', minimum: 0)]
|
||||
?float $maxPrice = null,
|
||||
#[Schema(description: 'Only dates with at least this many places available.', minimum: 1)]
|
||||
#[Schema(description: 'Lowest "ab" price per person in EUR.', minimum: 0)]
|
||||
?float $priceMin = null,
|
||||
#[Schema(description: 'Highest "ab" price per person in EUR.', minimum: 0)]
|
||||
?float $priceMax = null,
|
||||
#[Schema(description: 'Group size: only dates bookable for this many people with enough places left.', minimum: 1)]
|
||||
?int $persons = null,
|
||||
#[Schema(description: 'Accommodation type.', enum: ['Ferienwohnung', 'Sportclub', 'Apartment', 'Gästehaus', 'Pension'])]
|
||||
?string $accommodation = null,
|
||||
#[Schema(description: 'Included meals, matched against the board items by word: "Abendessen" (dinner; with "Frühstück" this is half board), "Frühstück", "Lunchpaket", "Vollpension", "Selbstversorger". "Halbpension" itself does not occur.')]
|
||||
?string $board = null,
|
||||
#[Schema(description: 'Room type by capacity: EZ (single), DZ (double), 3er-8er, 9er+, or "mit anderen" (a bed shared with other guests).', enum: ['EZ', 'DZ', '3er', '4er', '5er', '6er', '7er', '8er', '9er+', 'mit anderen'])]
|
||||
?string $roomType = null,
|
||||
#[Schema(description: 'Minimum number of nights. The website\'s "long" trips are 5 or more.', minimum: 0)]
|
||||
?int $nightsMin = null,
|
||||
#[Schema(description: 'Maximum number of nights. The website\'s "short" trips are 4 or fewer.', minimum: 0)]
|
||||
?int $nightsMax = null,
|
||||
#[Schema(description: 'true for dates with a bookable bus, false for dates without one.')]
|
||||
?bool $bus = null,
|
||||
#[Schema(description: 'true for day trips only, false to exclude them.')]
|
||||
?bool $daytrip = null,
|
||||
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
|
||||
?string $site = null,
|
||||
#[Schema(description: 'Number of trips, default 5, at most 10.', minimum: 1, maximum: 10)]
|
||||
#[Schema(description: 'Number of results, default 10, at most 25.', minimum: 1, maximum: 25)]
|
||||
?int $limit = null,
|
||||
): CallToolResult {
|
||||
return $this->runner->run('search_trips', get_defined_vars(), function () use ($query, $dateFrom, $dateTo, $country, $region, $concept, $maxPrice, $persons, $daytrip, $site, $limit): array {
|
||||
$from = $this->date($dateFrom, 'dateFrom');
|
||||
$to = $this->date($dateTo, 'dateTo');
|
||||
$arguments = get_defined_vars();
|
||||
|
||||
if (null !== $from && null !== $to && $from > $to) {
|
||||
throw new ToolCallException('dateFrom must not be after dateTo.');
|
||||
}
|
||||
return $this->runner->run('search_trips', $arguments, function () use ($query, $period, $dateFrom, $dateTo, $country, $region, $concept, $priceMin, $priceMax, $persons, $accommodation, $board, $roomType, $nightsMin, $nightsMax, $bus, $daytrip, $site, $limit): array {
|
||||
[$from, $to] = $this->period($period, $dateFrom, $dateTo);
|
||||
|
||||
return $this->catalog->search(new TripCriteria(
|
||||
query: $this->text($query),
|
||||
@@ -66,15 +83,48 @@ final class SearchTripsTool
|
||||
country: $this->text($country),
|
||||
region: $this->text($region),
|
||||
concept: $this->text($concept),
|
||||
maxPrice: $maxPrice,
|
||||
priceMin: $priceMin,
|
||||
priceMax: $priceMax,
|
||||
persons: $persons,
|
||||
accommodation: null === $accommodation ? null : (HotelType::fromLabel($accommodation) ?? throw new ToolCallException('Unknown accommodation type.')),
|
||||
board: $this->text($board),
|
||||
roomType: null === $roomType ? null : (RoomType::tryFrom($roomType) ?? throw new ToolCallException('Unknown room type.')),
|
||||
nightsMin: $nightsMin,
|
||||
nightsMax: $nightsMax,
|
||||
bus: $bus,
|
||||
daytrip: $daytrip,
|
||||
site: $site,
|
||||
limit: max(1, min(10, $limit ?? 5)),
|
||||
limit: max(1, min(25, $limit ?? 10)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?\DateTimeImmutable, ?\DateTimeImmutable}
|
||||
*/
|
||||
private function period(?string $period, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (null !== $this->text($period)) {
|
||||
if (null !== $this->text($dateFrom) || null !== $this->text($dateTo)) {
|
||||
throw new ToolCallException('Use either period or dateFrom/dateTo, not both.');
|
||||
}
|
||||
|
||||
$range = $this->periods->find($period)
|
||||
?? throw new ToolCallException(sprintf('Unknown or past period "%s". Current periods: %s.', $period, implode(', ', $this->periods->labels()) ?: 'none'));
|
||||
|
||||
return [$range['from'], $range['to']];
|
||||
}
|
||||
|
||||
$from = $this->date($dateFrom, 'dateFrom');
|
||||
$to = $this->date($dateTo, 'dateTo');
|
||||
|
||||
if (null !== $from && null !== $to && $from > $to) {
|
||||
throw new ToolCallException('dateFrom must not be after dateTo.');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
private function date(?string $value, string $name): ?\DateTimeImmutable
|
||||
{
|
||||
if (null === $value || '' === trim($value)) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Chatbot;
|
||||
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
|
||||
/**
|
||||
* The named travel periods of the website search ("Reisezeitraum"), from the
|
||||
* `chatbot_travel_periods` parameter.
|
||||
*
|
||||
* Duplicated from the TYPO3 search configuration on purpose, and updated together with it
|
||||
* each season. Periods may overlap. A period whose end has passed is no longer offered.
|
||||
*/
|
||||
class TravelPeriods
|
||||
{
|
||||
/**
|
||||
* @param list<array{label: string, from: string, to: string}> $periods
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $periods,
|
||||
private readonly ClockInterface $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The date range of a current period, matched case-insensitively, or null.
|
||||
*
|
||||
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null
|
||||
*/
|
||||
public function find(string $label): ?array
|
||||
{
|
||||
foreach ($this->current() as $period) {
|
||||
if (0 === strcasecmp($period['label'], trim($label))) {
|
||||
return ['from' => new \DateTimeImmutable($period['from']), 'to' => new \DateTimeImmutable($period['to'])];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function labels(): array
|
||||
{
|
||||
return array_column($this->current(), 'label');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, from: string, to: string}>
|
||||
*/
|
||||
private function current(): array
|
||||
{
|
||||
$today = $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
|
||||
|
||||
return array_values(array_filter($this->periods, static fn (array $period): bool => $period['to'] >= $today));
|
||||
}
|
||||
}
|
||||
+85
-102
@@ -7,6 +7,9 @@ namespace App\Chatbot;
|
||||
use App\BusProNet\Utility\DateCodeUtility;
|
||||
use App\Chatbot\Search\ContentSearchQuery;
|
||||
use App\Entity\ContentDocument;
|
||||
use App\Enum\HotelType;
|
||||
use App\Enum\RoomType;
|
||||
use App\Model\Chatbot\TravelDateFacts;
|
||||
use App\Model\Chatbot\TripCriteria;
|
||||
use App\Model\Export;
|
||||
use App\Repository\ContentDocumentRepository;
|
||||
@@ -23,22 +26,18 @@ use Symfony\Component\Clock\ClockInterface;
|
||||
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
|
||||
* `code` it is.
|
||||
*
|
||||
* A room is bookable unless its status is `Buchungsstop`. `available` counts rooms, so the
|
||||
* places of a date x hotel are the sum of `available x maxPax` over its bookable rooms.
|
||||
* Room counts never leave this class: the website does not show them, so the tools say
|
||||
* only whether a date is freely bookable or on request.
|
||||
* search() mirrors the website's travel search, get() details one trip. A room is bookable
|
||||
* unless its status is `Buchungsstop`; places and prices are derived per date x hotel by
|
||||
* TravelDateFacts. Room counts never leave this class: the website does not show them, so
|
||||
* the tools say only whether a date is freely bookable or on request.
|
||||
*/
|
||||
class TripCatalog
|
||||
{
|
||||
/** The feed's only language for now; trips are joined in it. */
|
||||
public const LANGUAGE = 'de';
|
||||
|
||||
private const STATUS_BOOKING_STOP = 'Buchungsstop';
|
||||
private const STATUS_FREE = 'Frei';
|
||||
private const AVAILABILITY_FREE = 'frei';
|
||||
private const AVAILABILITY_ON_REQUEST = 'auf Anfrage';
|
||||
|
||||
private const NEXT_DATES = 3;
|
||||
private const DATES_WITH_PICKUPS = 3;
|
||||
private const BODY_LIMIT = 20000;
|
||||
private const HOTEL_BODY_LIMIT = 8000;
|
||||
@@ -54,56 +53,70 @@ class TripCatalog
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{results: list<array<string, mixed>>, dataAsOf: ?string}
|
||||
* The website's travel search: one result per product x hotel x travel date, filtered and
|
||||
* priced as the search page does (see TravelDateFacts and TripCriteria), sorted by date,
|
||||
* then price.
|
||||
*
|
||||
* @return array{results: list<array<string, mixed>>, total: int, dataAsOf: string}
|
||||
*/
|
||||
public function search(TripCriteria $criteria): array
|
||||
{
|
||||
$catalog = $this->load();
|
||||
$products = $this->candidateProducts($catalog, $criteria);
|
||||
$today = $this->today();
|
||||
$results = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$data = $product->getData() ?? [];
|
||||
$dates = [];
|
||||
$hotelCodes = [];
|
||||
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
|
||||
$header = null;
|
||||
|
||||
foreach ($catalog['rowsByCode'][$this->normalize($data['code'] ?? null)] ?? [] as $row) {
|
||||
$summary = $this->summarize($row);
|
||||
|
||||
if (!$this->qualifies($row, $summary, $criteria, $today)) {
|
||||
foreach ($catalog['rowsByCode'][$this->normalize($product->getData()['code'] ?? null)] ?? [] as $row) {
|
||||
if ($row['date']['from'] < $today) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateId = $row['date']['busProId'];
|
||||
$hotelCodes[$this->normalize($row['hotel']['code'])] = true;
|
||||
$current = $dates[$dateId] ?? null;
|
||||
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
|
||||
$hotelData = $hotel?->getData() ?? [];
|
||||
$facts = TravelDateFacts::fromExportRow($row);
|
||||
|
||||
$dates[$dateId] = [
|
||||
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$header ??= $this->tripHeader($catalog, $product);
|
||||
|
||||
$results[] = [
|
||||
'product' => $header['name'],
|
||||
'code' => $header['code'],
|
||||
'dateCode' => $row['date']['code'],
|
||||
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
|
||||
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
|
||||
'destination' => $header['destination'],
|
||||
'concept' => $header['concept'],
|
||||
'dateFrom' => $row['date']['from'],
|
||||
'dateTo' => $row['date']['to'],
|
||||
'priceFrom' => null === $current ? $summary['priceFrom'] : $this->minPrice($current['priceFrom'], $summary['priceFrom']),
|
||||
'availability' => self::AVAILABILITY_FREE === ($current['availability'] ?? null) ? self::AVAILABILITY_FREE : $summary['availability'],
|
||||
'nights' => $facts->nights,
|
||||
'priceFrom' => $facts->priceFrom,
|
||||
'includes' => $facts->includes(),
|
||||
'roomTypes' => array_map(static fn (RoomType $type): string => $type->value, $facts->roomTypes),
|
||||
'availability' => $facts->availability,
|
||||
'flags' => array_values(array_filter([
|
||||
true === ($hotelData['new'] ?? null) ? 'neu' : null,
|
||||
true === ($hotelData['earlybird'] ?? null) ? 'frühbucher' : null,
|
||||
$header['daytrip'] ? 'tagesfahrt' : null,
|
||||
])),
|
||||
'url' => $header['url'],
|
||||
'site' => $header['site'],
|
||||
];
|
||||
}
|
||||
|
||||
if ([] === $dates) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usort($dates, static fn (array $a, array $b): int => $a['dateFrom'] <=> $b['dateFrom']);
|
||||
|
||||
$results[] = $this->tripHeader($catalog, $product) + [
|
||||
'hotels' => $this->hotelNames($catalog, array_keys($hotelCodes)),
|
||||
'matchingDates' => count($dates),
|
||||
'nextDates' => array_slice($dates, 0, self::NEXT_DATES),
|
||||
];
|
||||
}
|
||||
|
||||
usort($results, static fn (array $a, array $b): int => [$a['nextDates'][0]['dateFrom'], $a['name']] <=> [$b['nextDates'][0]['dateFrom'], $b['name']]);
|
||||
usort($results, static fn (array $a, array $b): int => [$a['dateFrom'], $a['priceFrom'] ?? \PHP_FLOAT_MAX, $a['product'], $a['hotel']]
|
||||
<=> [$b['dateFrom'], $b['priceFrom'] ?? \PHP_FLOAT_MAX, $b['product'], $b['hotel']]);
|
||||
|
||||
return ['results' => array_slice($results, 0, $criteria->limit), 'dataAsOf' => $this->dataAsOf($catalog['export'])];
|
||||
return [
|
||||
'results' => array_slice($results, 0, $criteria->limit),
|
||||
'total' => count($results),
|
||||
'dataAsOf' => $this->dataAsOf($catalog['export']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,49 +421,47 @@ class TripCatalog
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* The search page's filters on one travel date at one hotel. `persons` checks both the
|
||||
* page's condition (the smallest group a room takes) and that enough places are left.
|
||||
*
|
||||
* @return array{priceFrom: ?float, places: int, availability: ?string}
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function summarize(array $row): array
|
||||
private function matches(array $row, TravelDateFacts $facts, ?HotelType $hotelType, TripCriteria $criteria): bool
|
||||
{
|
||||
$priceFrom = null;
|
||||
$places = 0;
|
||||
$availability = null;
|
||||
if (!$facts->isBookable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($row['rooms'] ?? [] as $room) {
|
||||
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
if ((null !== $criteria->dateFrom && $row['date']['from'] < $criteria->dateFrom->format('Y-m-d'))
|
||||
|| (null !== $criteria->dateTo && $row['date']['to'] > $criteria->dateTo->format('Y-m-d'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$places += (int) ($room['available'] ?? 0) * (int) ($room['maxPax'] ?? 0);
|
||||
$availability = self::STATUS_FREE === ($room['status'] ?? null) || self::AVAILABILITY_FREE === $availability
|
||||
? self::AVAILABILITY_FREE
|
||||
: self::AVAILABILITY_ON_REQUEST;
|
||||
if ((null !== $criteria->priceMin || null !== $criteria->priceMax)
|
||||
&& (null === $facts->priceFrom
|
||||
|| (null !== $criteria->priceMin && $facts->priceFrom < $criteria->priceMin)
|
||||
|| (null !== $criteria->priceMax && $facts->priceFrom > $criteria->priceMax))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_int($room['price'] ?? null) || is_float($room['price'] ?? null)) {
|
||||
$priceFrom = $this->minPrice($priceFrom, (float) $room['price']);
|
||||
if (null !== $criteria->persons && (($facts->minPax ?? 0) > $criteria->persons || $facts->places < $criteria->persons)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $criteria->board) {
|
||||
$wanted = mb_strtolower($criteria->board);
|
||||
$boards = array_filter($facts->boards, static fn (string $board): bool => str_contains(mb_strtolower($board), $wanted));
|
||||
|
||||
if ([] === $boards) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return ['priceFrom' => $priceFrom, 'places' => $places, 'availability' => $availability];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array{priceFrom: ?float, places: int, availability: ?string} $summary
|
||||
*/
|
||||
private function qualifies(array $row, array $summary, TripCriteria $criteria, string $today): bool
|
||||
{
|
||||
$from = $row['date']['from'];
|
||||
$to = $row['date']['to'];
|
||||
|
||||
return $from >= $today
|
||||
&& null !== $summary['availability']
|
||||
&& (null === $criteria->dateFrom || $to >= $criteria->dateFrom->format('Y-m-d'))
|
||||
&& (null === $criteria->dateTo || $from <= $criteria->dateTo->format('Y-m-d'))
|
||||
&& (null === $criteria->maxPrice || (null !== $summary['priceFrom'] && $summary['priceFrom'] <= $criteria->maxPrice))
|
||||
&& (null === $criteria->persons || $summary['places'] >= $criteria->persons);
|
||||
return (null === $criteria->accommodation || $hotelType === $criteria->accommodation)
|
||||
&& (null === $criteria->roomType || $facts->hasRoomType($criteria->roomType))
|
||||
&& (null === $criteria->nightsMin || $facts->nights >= $criteria->nightsMin)
|
||||
&& (null === $criteria->nightsMax || $facts->nights <= $criteria->nightsMax)
|
||||
&& (null === $criteria->bus || $facts->busAvailable === $criteria->bus);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,25 +489,6 @@ class TripCatalog
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $catalog
|
||||
* @param list<string> $hotelCodes normalized
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function hotelNames(array $catalog, array $hotelCodes): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($hotelCodes as $code) {
|
||||
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
|
||||
if (null !== $hotel && '' !== $hotel->getTitle()) {
|
||||
$names[$hotel->getTitle()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $catalog
|
||||
* @param list<string> $hotelCodes normalized codes of the hotels with dates
|
||||
@@ -552,7 +544,7 @@ class TripCatalog
|
||||
|
||||
$rooms = [];
|
||||
foreach ($row['rooms'] ?? [] as $room) {
|
||||
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
|
||||
if (TravelDateFacts::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -561,7 +553,7 @@ class TripCatalog
|
||||
'board' => $room['boardCode'] ?? null,
|
||||
'nights' => $room['nights'] ?? null,
|
||||
'price' => $room['price'] ?? null,
|
||||
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? self::AVAILABILITY_FREE : self::AVAILABILITY_ON_REQUEST,
|
||||
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? TravelDateFacts::AVAILABILITY_FREE : TravelDateFacts::AVAILABILITY_ON_REQUEST,
|
||||
], static fn (mixed $value): bool => null !== $value);
|
||||
}
|
||||
|
||||
@@ -654,15 +646,6 @@ class TripCatalog
|
||||
return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]";
|
||||
}
|
||||
|
||||
private function minPrice(?float $a, ?float $b): ?float
|
||||
{
|
||||
if (null === $a || null === $b) {
|
||||
return $a ?? $b;
|
||||
}
|
||||
|
||||
return min($a, $b);
|
||||
}
|
||||
|
||||
private function today(): string
|
||||
{
|
||||
return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* The accommodation type of a TYPO3 hotel record (`type` in the content feed), as the
|
||||
* website's travel search filters it ("Unterkunft"). Values and labels follow the website's
|
||||
* Hotel::$typeLabels.
|
||||
*/
|
||||
enum HotelType: int
|
||||
{
|
||||
case HolidayFlat = 1;
|
||||
case SportsClub = 2;
|
||||
case Apartment = 3;
|
||||
case GuestHouse = 4;
|
||||
case Pension = 5;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HolidayFlat => 'Ferienwohnung',
|
||||
self::SportsClub => 'Sportclub',
|
||||
self::Apartment => 'Apartment',
|
||||
self::GuestHouse => 'Gästehaus',
|
||||
self::Pension => 'Pension',
|
||||
};
|
||||
}
|
||||
|
||||
public static function fromLabel(string $label): ?self
|
||||
{
|
||||
foreach (self::cases() as $case) {
|
||||
if (0 === strcasecmp($case->label(), trim($label))) {
|
||||
return $case;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Room type as the website's travel search filters it ("Zimmerarten").
|
||||
*
|
||||
* Taken from the room's capacity, as TYPO3's RoomTypeResolver does since the room-code
|
||||
* mapping was retired: `maxPax` 1-8 maps directly, 9 and up is "9er+". A bed shared with
|
||||
* other customers is sold per bed (`maxPax` 1), so it is recognised by its label instead;
|
||||
* without that, a bed in a dorm would count as an Einzelzimmer.
|
||||
*/
|
||||
enum RoomType: string
|
||||
{
|
||||
case ForOne = 'EZ';
|
||||
case ForTwo = 'DZ';
|
||||
case ForThree = '3er';
|
||||
case ForFour = '4er';
|
||||
case ForFive = '5er';
|
||||
case ForSix = '6er';
|
||||
case ForSeven = '7er';
|
||||
case ForEight = '8er';
|
||||
case ForNinePlus = '9er+';
|
||||
case WithOthers = 'mit anderen';
|
||||
|
||||
/** The "Babypreis 0-2 Jährige" row: priced like a room, but not one. */
|
||||
public const BABY_CODE = 'Baby';
|
||||
|
||||
/** Label phrases (lower case) that mark a bed shared with other customers. */
|
||||
private const WITH_OTHERS_MARKERS = ['bett im', 'mit ander'];
|
||||
|
||||
/**
|
||||
* Null for the baby row, which is not a room.
|
||||
*/
|
||||
public static function classify(?string $code, ?string $label, ?int $maxPax, ?int $minPax): ?self
|
||||
{
|
||||
if (self::BABY_CODE === trim((string) $code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = mb_strtolower((string) $label);
|
||||
foreach (self::WITH_OTHERS_MARKERS as $marker) {
|
||||
if (str_contains($label, $marker)) {
|
||||
return self::WithOthers;
|
||||
}
|
||||
}
|
||||
|
||||
$pax = ($maxPax ?? 0) > 0 ? (int) $maxPax : (int) $minPax;
|
||||
|
||||
return match (true) {
|
||||
$pax >= 9 => self::ForNinePlus,
|
||||
$pax >= 1 => [1 => self::ForOne, self::ForTwo, self::ForThree, self::ForFour, self::ForFive, self::ForSix, self::ForSeven, self::ForEight][$pax],
|
||||
// No usable occupancy: TYPO3 files it under "mit anderen" as well.
|
||||
default => self::WithOthers,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Chatbot;
|
||||
|
||||
use App\Enum\RoomType;
|
||||
|
||||
/**
|
||||
* What the website's travel search knows about one travel date at one hotel, derived from
|
||||
* a product export row the way TYPO3's DateImportService derives it, with two deliberate
|
||||
* differences: rooms in `Buchungsstop` are not bookable, and neither is the baby row.
|
||||
*
|
||||
* - places: Σ available × maxPax over the bookable rooms (`available` counts rooms);
|
||||
* - priceFrom ("ab" price): cheapest bookable room with places left, else cheapest bookable room;
|
||||
* - minPax: the smallest group a bookable room takes;
|
||||
* - inclusions from the selection groups (7 Skipass, 71 Verpflegung, 86 Programm, 87 Kurse,
|
||||
* 89 Events) and the outbound bus service (a price of 0 means included).
|
||||
*/
|
||||
final readonly class TravelDateFacts
|
||||
{
|
||||
public const AVAILABILITY_FREE = 'frei';
|
||||
public const AVAILABILITY_ON_REQUEST = 'auf Anfrage';
|
||||
|
||||
public const STATUS_BOOKING_STOP = 'Buchungsstop';
|
||||
private const STATUS_FREE = 'Frei';
|
||||
|
||||
private const GROUP_SKIPASS = 7;
|
||||
private const GROUP_BOARD = 71;
|
||||
private const GROUP_PROGRAMME = 86;
|
||||
private const GROUP_COURSES = 87;
|
||||
private const GROUP_EVENTS = 89;
|
||||
|
||||
/**
|
||||
* @param list<RoomType> $roomTypes distinct, of the bookable rooms
|
||||
* @param list<string> $boards included board labels
|
||||
*/
|
||||
private function __construct(
|
||||
public int $nights,
|
||||
public ?float $priceFrom,
|
||||
public int $places,
|
||||
public ?int $minPax,
|
||||
public array $roomTypes,
|
||||
public ?string $availability,
|
||||
public array $boards,
|
||||
public bool $skipass,
|
||||
public bool $programme,
|
||||
public bool $courses,
|
||||
public bool $events,
|
||||
public bool $busAvailable,
|
||||
public bool $busIncluded,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row a row of ProductExportBuilder
|
||||
*/
|
||||
public static function fromExportRow(array $row): self
|
||||
{
|
||||
$priceFrom = null;
|
||||
$priceFromWithPlaces = null;
|
||||
$places = 0;
|
||||
$minPax = null;
|
||||
$roomTypes = [];
|
||||
$availability = null;
|
||||
|
||||
foreach ($row['rooms'] ?? [] as $room) {
|
||||
$type = RoomType::classify($room['code'] ?? null, $room['label'] ?? null, $room['maxPax'] ?? null, $room['minPax'] ?? null);
|
||||
|
||||
if (null === $type || self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$available = (int) ($room['available'] ?? 0);
|
||||
$places += $available * (int) ($room['maxPax'] ?? 0);
|
||||
$roomTypes[$type->value] = $type;
|
||||
$availability = self::STATUS_FREE === ($room['status'] ?? null) || self::AVAILABILITY_FREE === $availability
|
||||
? self::AVAILABILITY_FREE
|
||||
: self::AVAILABILITY_ON_REQUEST;
|
||||
|
||||
if (is_int($room['minPax'] ?? null) && (null === $minPax || $room['minPax'] < $minPax)) {
|
||||
$minPax = $room['minPax'];
|
||||
}
|
||||
|
||||
if (is_int($room['price'] ?? null) || is_float($room['price'] ?? null)) {
|
||||
$price = (float) $room['price'];
|
||||
$priceFrom = null === $priceFrom ? $price : min($priceFrom, $price);
|
||||
|
||||
if ($available > 0) {
|
||||
$priceFromWithPlaces = null === $priceFromWithPlaces ? $price : min($priceFromWithPlaces, $price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($row['selectionGroups'] ?? [] as $group) {
|
||||
$groups[(int) ($group['busProId'] ?? 0)] = array_values(array_filter(array_map(
|
||||
static fn (array $selection): string => trim((string) ($selection['label'] ?? '')),
|
||||
is_array($group['selections'] ?? null) ? $group['selections'] : [],
|
||||
)));
|
||||
}
|
||||
|
||||
$busAvailable = false;
|
||||
$busIncluded = false;
|
||||
foreach ($row['transportServices'] ?? [] as $service) {
|
||||
if ('BUS' !== ($service['subType'] ?? null) || 'RUECK' === ($service['direction'] ?? null) || self::STATUS_BOOKING_STOP === ($service['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$busAvailable = true;
|
||||
$busIncluded = $busIncluded || 0.0 === (float) ($service['price'] ?? -1);
|
||||
}
|
||||
|
||||
$from = new \DateTimeImmutable($row['date']['from']);
|
||||
$to = new \DateTimeImmutable($row['date']['to']);
|
||||
|
||||
return new self(
|
||||
nights: (int) $from->diff($to)->days,
|
||||
priceFrom: $priceFromWithPlaces ?? $priceFrom,
|
||||
places: $places,
|
||||
minPax: $minPax,
|
||||
roomTypes: array_values($roomTypes),
|
||||
availability: $availability,
|
||||
boards: $groups[self::GROUP_BOARD] ?? [],
|
||||
skipass: [] !== ($groups[self::GROUP_SKIPASS] ?? []),
|
||||
programme: [] !== ($groups[self::GROUP_PROGRAMME] ?? []),
|
||||
courses: [] !== ($groups[self::GROUP_COURSES] ?? []),
|
||||
events: [] !== ($groups[self::GROUP_EVENTS] ?? []),
|
||||
busAvailable: $busAvailable,
|
||||
busIncluded: $busIncluded,
|
||||
);
|
||||
}
|
||||
|
||||
public function isBookable(): bool
|
||||
{
|
||||
return null !== $this->availability;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the price includes, as the website lists it on a result.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function includes(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$this->skipass ? 'Skipass' : null,
|
||||
$this->busIncluded ? 'Bus' : null,
|
||||
...$this->boards,
|
||||
$this->programme ? 'Programm' : null,
|
||||
$this->courses ? 'Kurse' : null,
|
||||
$this->events ? 'Events' : null,
|
||||
]));
|
||||
}
|
||||
|
||||
public function hasRoomType(RoomType $type): bool
|
||||
{
|
||||
return in_array($type, $this->roomTypes, true);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Chatbot;
|
||||
|
||||
use App\Enum\HotelType;
|
||||
use App\Enum\RoomType;
|
||||
|
||||
/**
|
||||
* The filters of the search_trips tool, validated.
|
||||
* The filters of the search_trips tool, validated. They mirror the website's travel search:
|
||||
* the travel period is matched by containment (start on or after `dateFrom`, end on or
|
||||
* before `dateTo`), prices apply to the "ab" price.
|
||||
*/
|
||||
final readonly class TripCriteria
|
||||
{
|
||||
@@ -16,11 +21,18 @@ final readonly class TripCriteria
|
||||
public ?string $country = null,
|
||||
public ?string $region = null,
|
||||
public ?string $concept = null,
|
||||
public ?float $maxPrice = null,
|
||||
public ?float $priceMin = null,
|
||||
public ?float $priceMax = null,
|
||||
public ?int $persons = null,
|
||||
public ?HotelType $accommodation = null,
|
||||
public ?string $board = null,
|
||||
public ?RoomType $roomType = null,
|
||||
public ?int $nightsMin = null,
|
||||
public ?int $nightsMax = null,
|
||||
public ?bool $bus = null,
|
||||
public ?bool $daytrip = null,
|
||||
public ?string $site = null,
|
||||
public int $limit = 5,
|
||||
public int $limit = 10,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user