diff --git a/config/services.yaml b/config/services.yaml index 2186f6db..590cccba 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -26,6 +26,17 @@ parameters: LENA20: '2027-03-04' BOTSCHAFTER2627: '2027-04-30' + # Named travel periods of the website search ("Reisezeitraum"), offered by the chatbot's + # search_trips. Duplicated from the TYPO3 search configuration on purpose: update both each + # season. Periods may overlap; one whose end has passed is no longer offered. + chatbot_travel_periods: + - { label: 'Vorsaison', from: '2026-11-01', to: '2026-12-20' } + - { label: 'um Weihnachten', from: '2026-12-20', to: '2026-12-28' } + - { label: 'Silvester', from: '2026-12-26', to: '2027-01-03' } + - { label: '1 Januarwoche', from: '2027-01-02', to: '2027-01-09' } + - { label: 'Nachsaison', from: '2027-03-20', to: '2027-05-01' } + - { label: 'Ostern', from: '2027-03-20', to: '2027-04-03' } + # MailJet list ids and their labels mailjet_lists: 10321569: 'E&P Newsletter' @@ -338,6 +349,10 @@ services: $baseUrl: '%env(APP_CMS_API_BASE_URL)%' $apiKey: '%env(APP_CMS_API_KEY)%' + App\Chatbot\TravelPeriods: + arguments: + $periods: '%chatbot_travel_periods%' + App\Security\McpAccessTokenHandler: arguments: $tokens: '%env(APP_MCP_TOKENS)%' diff --git a/docs/chatbot-mcp.md b/docs/chatbot-mcp.md index 9c99ef48..e4c9f217 100644 --- a/docs/chatbot-mcp.md +++ b/docs/chatbot-mcp.md @@ -39,7 +39,7 @@ that prices and availability are a snapshot (`dataAsOf`) and only the booking pa | Tool | Use | Returns | |---|---|---| -| `search_trips` | Which trips exist, when, where, how much | Up to 10 trips with their next 3 matching dates, `priceFrom`, availability, `dataAsOf` | +| `search_trips` | The website's travel search: which trips exist, when, where, how much | One result per trip × hotel × travel date (default 10, max 25), with "ab" price, nights, inclusions, room types, availability, link; `total` and `dataAsOf` | | `get_trip` | One trip by product `code`, or one date of it by date code such as `SSLMP061226` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates | | `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt | | `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters | @@ -52,15 +52,40 @@ Trip rules (`App\Chatbot\TripCatalog`): - TYPO3 products are joined to the export on `product.code`. Several products can share a code, and each is listed as its own trip. Hotels are joined on any entry of the TYPO3 hotel's `codes` (BusPro uses the matchcode aliases). A code that is one hotel's own and another's alias resolves to its owner. -- A room is bookable unless its status is `Buchungsstop`. `available` counts rooms, so the places of a - date at a hotel are `Σ available × maxPax` over its bookable rooms. `persons` filters on that sum, and - `maxPrice` on the lowest bookable room price. The tools never output room counts, only `frei` or - `auf Anfrage`. +- A room is bookable unless its status is `Buchungsstop`. The baby row (room code `Baby`) is not a room. + `available` counts rooms, so the places of a date at a hotel are `Σ available × maxPax` over its + bookable rooms. The tools never output room counts, only `frei` or `auf Anfrage`. - `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. - 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 +product export. Its rules follow TYPO3's `DateImportService` and search repository, derived per date and +hotel in `App\Model\Chatbot\TravelDateFacts`: + +- **"ab" price:** the cheapest bookable room with places left, otherwise the cheapest bookable room. + `priceMin`/`priceMax` apply to it. The site's price bands are presets of this. +- **Travel period:** `dateFrom`/`dateTo` match by containment (start on or after, end on or before), as on + the site, not by overlap. `period` names one of the site's presets, from the `chatbot_travel_periods` + parameter in `config/services.yaml`. It's duplicated from the TYPO3 search configuration on purpose, so + update both each season. Expired periods are no longer offered. +- **`persons`:** the site's condition (a room takes groups this small: smallest `minPax` ≤ persons) **and** + enough places left. The site checks only the first. +- **Room type:** capacity, as TYPO3's `RoomTypeResolver` (`App\Enum\RoomType`): `maxPax` 1–8, 9 or more + is `9er+`. A label containing "bett im" or "mit ander" is `mit anderen` (a shared bed, sold per bed). +- **Accommodation:** the TYPO3 hotel `type` (1 Ferienwohnung, 2 Sportclub, 3 Apartment, 4 Gästehaus, + 5 Pension), `App\Enum\HotelType`. +- **Board:** the items of selection group 71 ("4x Abendessen", "Frühstück inkl. … Lunchpaket", "Vollpension", + …), matched by word against any item. The site keys on the first item only. "Halbpension" occurs in no + label, so dinner is asked as "Abendessen". +- **Inclusions:** selection groups 7 (Skipass), 71 (board), 86 (Programm), 87 (Kurse), 89 (Events), and the + outbound bus when its price is 0. `bus: true` asks for any bookable outbound bus. +- **Nights:** days between start and end. The site's "short" is ≤ 4 and its "long" is ≥ 5. +- **Order:** by start date, then "ab" price. The site sorts by concept first, which the feed doesn't carry. +- Results link to the product page. Links with the hotel preselected and the site's "Restplätze" badge need + two fields the TYPO3 content feed doesn't send yet (`hotelUrls`, `lowContingent`). + Search (`App\Chatbot\Search\ContentSearch`) is MariaDB full text in boolean mode over `title, breadcrumb, body_md`. Each term becomes a prefix match, because InnoDB has no German stemming, and German stopwords are dropped in PHP. Relevance is weighted by type: products and hotels, then diff --git a/src/Chatbot/Mcp/Tool/SearchTripsTool.php b/src/Chatbot/Mcp/Tool/SearchTripsTool.php index fff579e2..4ef76ec3 100644 --- a/src/Chatbot/Mcp/Tool/SearchTripsTool.php +++ b/src/Chatbot/Mcp/Tool/SearchTripsTool.php @@ -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)) { diff --git a/src/Chatbot/TravelPeriods.php b/src/Chatbot/TravelPeriods.php new file mode 100644 index 00000000..6c1dacd6 --- /dev/null +++ b/src/Chatbot/TravelPeriods.php @@ -0,0 +1,60 @@ + $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 + */ + public function labels(): array + { + return array_column($this->current(), 'label'); + } + + /** + * @return list + */ + 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)); + } +} diff --git a/src/Chatbot/TripCatalog.php b/src/Chatbot/TripCatalog.php index cfe2a35e..5c9d89b3 100644 --- a/src/Chatbot/TripCatalog.php +++ b/src/Chatbot/TripCatalog.php @@ -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>, 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>, 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 $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 $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 $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 $catalog - * @param list $hotelCodes normalized - * - * @return list - */ - 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 $catalog * @param list $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'); diff --git a/src/Enum/HotelType.php b/src/Enum/HotelType.php new file mode 100644 index 00000000..f57cb61b --- /dev/null +++ b/src/Enum/HotelType.php @@ -0,0 +1,41 @@ + '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; + } +} diff --git a/src/Enum/RoomType.php b/src/Enum/RoomType.php new file mode 100644 index 00000000..ecc086a4 --- /dev/null +++ b/src/Enum/RoomType.php @@ -0,0 +1,59 @@ + 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, + }; + } +} diff --git a/src/Model/Chatbot/TravelDateFacts.php b/src/Model/Chatbot/TravelDateFacts.php new file mode 100644 index 00000000..3495fefe --- /dev/null +++ b/src/Model/Chatbot/TravelDateFacts.php @@ -0,0 +1,160 @@ + $roomTypes distinct, of the bookable rooms + * @param list $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 $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 + */ + 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); + } +} diff --git a/src/Model/Chatbot/TripCriteria.php b/src/Model/Chatbot/TripCriteria.php index a3cd1b73..e1c8d0cf 100644 --- a/src/Model/Chatbot/TripCriteria.php +++ b/src/Model/Chatbot/TripCriteria.php @@ -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, ) { } } diff --git a/tests/Chatbot/TravelPeriodsTest.php b/tests/Chatbot/TravelPeriodsTest.php new file mode 100644 index 00000000..d8816364 --- /dev/null +++ b/tests/Chatbot/TravelPeriodsTest.php @@ -0,0 +1,42 @@ +periods = new TravelPeriods([ + ['label' => 'Herbst', 'from' => '2026-09-01', 'to' => '2026-10-07'], + ['label' => 'Silvester', 'from' => '2026-12-26', 'to' => '2027-01-03'], + ['label' => '1 Januarwoche', 'from' => '2027-01-02', 'to' => '2027-01-09'], + ], new MockClock('2026-10-08 10:00:00')); + } + + public function testFindsACurrentPeriodByLabelIgnoringCase(): void + { + $range = $this->periods->find(' silvester '); + + self::assertSame('2026-12-26', $range['from']->format('Y-m-d')); + self::assertSame('2027-01-03', $range['to']->format('Y-m-d')); + } + + public function testAPeriodWhoseEndHasPassedIsNoLongerOffered(): void + { + self::assertNull($this->periods->find('Herbst')); + self::assertSame(['Silvester', '1 Januarwoche'], $this->periods->labels()); + } + + public function testAnUnknownLabelIsNull(): void + { + self::assertNull($this->periods->find('Pfingsten')); + } +} diff --git a/tests/Chatbot/TripCatalogTest.php b/tests/Chatbot/TripCatalogTest.php index 03902b19..1f00ecde 100644 --- a/tests/Chatbot/TripCatalogTest.php +++ b/tests/Chatbot/TripCatalogTest.php @@ -7,6 +7,8 @@ namespace App\Tests\Chatbot; use App\Chatbot\TripCatalog; use App\Entity\ContentDocument; use App\Enum\ContentFetchState; +use App\Enum\HotelType; +use App\Enum\RoomType; use App\Model\Chatbot\TripCriteria; use App\Model\Export; use App\Repository\ContentDocumentRepository; @@ -17,47 +19,91 @@ use Symfony\Component\Clock\MockClock; class TripCatalogTest extends TestCase { - public function testSearchReturnsUpcomingDatesWithTheLowestBookablePrice(): void + public function testSearchReturnsOneResultPerTravelDateAndHotelLikeTheWebsite(): void { $result = $this->catalog([ $this->row('VTH', 1, '2026-09-01', 'HOT1', [$this->room(100)]), - $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500), $this->room(450, status: 'Anfrage'), $this->room(99, status: 'Buchungsstop')]), $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520, status: 'Anfrage')]), + $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500), $this->room(450, available: 0), $this->room(99, status: 'Buchungsstop')], withExtras: true), + $this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]), ])->search(new TripCriteria()); - self::assertCount(1, $result['results']); - $trip = $result['results'][0]; - self::assertSame('VTH', $trip['code']); - self::assertSame('Val Thorens Apartments', $trip['name']); - self::assertSame('https://www.ep-reisen.de/val-thorens/', $trip['url']); - self::assertSame(['country' => 'Frankreich'], $trip['destination']); - self::assertSame(['Hotel Eins'], $trip['hotels']); + self::assertSame(3, $result['total']); + self::assertSame(['VTH2', 'VTH2', 'VTH3'], array_column($result['results'], 'dateCode')); self::assertSame([ - ['dateCode' => 'VTH2', 'dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'], - ['dateCode' => 'VTH3', 'dateFrom' => '2027-01-09', 'dateTo' => '2027-01-16', 'priceFrom' => 520.0, 'availability' => 'auf Anfrage'], - ], $trip['nextDates']); + 'product' => 'Val Thorens Apartments', + 'code' => 'VTH', + 'dateCode' => 'VTH2', + 'hotel' => 'Hotel Zwei', + 'accommodation' => null, + 'destination' => ['country' => 'Frankreich'], + 'concept' => 'Skiurlaub ohne Programm', + 'dateFrom' => '2026-12-05', + 'dateTo' => '2026-12-12', + 'nights' => 7, + 'priceFrom' => 480.0, + 'includes' => [], + 'roomTypes' => ['DZ'], + 'availability' => 'frei', + 'flags' => [], + 'url' => 'https://www.ep-reisen.de/val-thorens/', + 'site' => 'ep-reisen', + ], $result['results'][0], 'sorted by date, then the cheaper hotel first'); + + $hotelOne = $result['results'][1]; + self::assertSame('Hotel Eins', $hotelOne['hotel']); + self::assertSame('Sportclub', $hotelOne['accommodation']); + self::assertSame(500.0, $hotelOne['priceFrom'], 'the cheapest room with places left; the stopped room does not count'); + self::assertSame(['Skipass', 'Bus', 'Halbpension', 'Programm'], $hotelOne['includes']); + self::assertSame(['neu'], $hotelOne['flags']); + self::assertSame('auf Anfrage', $result['results'][2]['availability']); self::assertSame('2026-10-08T06:00:00+00:00', $result['dataAsOf']); + self::assertSame(1, count($this->catalog([$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(1)]), $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(1)])])->search(new TripCriteria(limit: 1))['results'])); } - public function testPersonsCountsPlacesAcrossBookableRoomsOnly(): void + public function testPersonsNeedsAGroupARoomTakesAndEnoughPlaces(): void { $rows = [ // 2 rooms x 2 pax + 1 x 1 pax = 5 places; the stopped room's 10 do not count. $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(400, available: 2, maxPax: 2), $this->room(300, available: 1, maxPax: 1), $this->room(200, available: 10, maxPax: 1, status: 'Buchungsstop')]), + // Only takes groups of 4 or more. + $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(300, available: 3, maxPax: 6, minPax: 4)]), ]; - self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(persons: 5))['results']); - self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(persons: 6))['results']); + self::assertSame(['VTH2', 'VTH3'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 5))['results'], 'dateCode')); + self::assertSame(['VTH3'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 6))['results'], 'dateCode')); + self::assertSame(['VTH2'], array_column($this->catalog($rows)->search(new TripCriteria(persons: 2))['results'], 'dateCode')); } - public function testDatePeriodMatchesOnOverlapAndMaxPriceOnTheLowestPrice(): void + public function testTheTravelPeriodContainsTheTripAndPricesApplyToTheAbPrice(): void { $rows = [$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450), $this->room(700)])]; - self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-12'), dateTo: new \DateTimeImmutable('2026-12-31')))['results']); - self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-13')))['results']); - self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(maxPrice: 450))['results']); - self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(maxPrice: 449.99))['results']); + self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-05'), dateTo: new \DateTimeImmutable('2026-12-12')))['results']); + self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-01'), dateTo: new \DateTimeImmutable('2026-12-10')))['results'], 'overlap is not enough'); + self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(priceMin: 400, priceMax: 450))['results']); + self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(priceMax: 449.99))['results']); + self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(priceMin: 451))['results']); + } + + public function testAccommodationBoardRoomTypeNightsAndBusFilters(): void + { + $rows = [ + $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(400, label: 'Bett im Mehrbettzimmer', maxPax: 1), $this->room(500, code: 'Baby', maxPax: 1)], withExtras: true), + $this->row('VTH', 3, '2027-01-09', 'HOT2', [$this->room(300, maxPax: 4)], days: 3), + ]; + $search = fn (TripCriteria $criteria): array => array_column($this->catalog($rows)->search($criteria)['results'], 'dateCode'); + + self::assertSame(['VTH2'], $search(new TripCriteria(accommodation: HotelType::SportsClub))); + self::assertSame(['VTH2'], $search(new TripCriteria(board: 'halbpension'))); + self::assertSame([], $search(new TripCriteria(board: 'Vollpension'))); + self::assertSame(['VTH2'], $search(new TripCriteria(roomType: RoomType::WithOthers))); + self::assertSame([], $search(new TripCriteria(roomType: RoomType::ForOne)), 'a shared bed is no Einzelzimmer, and the baby row is no room'); + self::assertSame(['VTH3'], $search(new TripCriteria(roomType: RoomType::ForFour))); + self::assertSame(['VTH3'], $search(new TripCriteria(nightsMax: 4))); + self::assertSame(['VTH2'], $search(new TripCriteria(nightsMin: 5))); + self::assertSame(['VTH2'], $search(new TripCriteria(bus: true))); + self::assertSame(['VTH3'], $search(new TripCriteria(bus: false))); } public function testCountryAndConceptAreResolvedThroughTheContentRecords(): void @@ -124,7 +170,7 @@ class TripCatalogTest extends TestCase foreach ([ $this->record('product', 1, 'Val Thorens Apartments', 'https://www.ep-reisen.de/val-thorens/', ['code' => 'VTH', 'countryUid' => 2, 'conceptUid' => 7, 'hotelUids' => [10], 'daytrip' => false, 'bookable' => true]), $this->record('product', 2, 'Gruppenreise Uni X', 'https://www.ep-reisen.de/gruppe/', ['code' => 'GRP', 'countryUid' => 2, 'searchable' => false, 'bookable' => true]), - $this->record('hotel', 10, 'Hotel Eins', null, ['code' => 'HOT1', 'codes' => ['HOT1', 'HOT1ALIAS', 'SHARED'], 'facts' => ['wifi' => true, 'sauna' => null]]), + $this->record('hotel', 10, 'Hotel Eins', null, ['code' => 'HOT1', 'codes' => ['HOT1', 'HOT1ALIAS', 'SHARED'], 'type' => 2, 'new' => true, 'earlybird' => false, 'facts' => ['wifi' => true, 'sauna' => null]]), $this->record('hotel', 11, 'Hotel Zwei', null, ['code' => 'SHARED', 'codes' => ['HOT2']]), $this->record('country', 2, 'Frankreich', null, ['code' => 'FR']), $this->record('concept', 7, 'Skiurlaub ohne Programm', null, ['code' => 'ski']), @@ -158,14 +204,25 @@ class TripCatalogTest extends TestCase * * @return array */ - private function row(string $code, int $dateId, string $from, string $hotelCode, array $rooms): array + private function row(string $code, int $dateId, string $from, string $hotelCode, array $rooms, bool $withExtras = false, int $days = 7): array { $dateFrom = new \DateTimeImmutable($from); return [ 'product' => ['code' => $code, 'busProId' => 1], - 'date' => ['code' => $code.$dateId, 'busProId' => $dateId, 'from' => $from, 'to' => $dateFrom->modify('+7 days')->format('Y-m-d'), 'type' => 'F'], + 'date' => ['code' => $code.$dateId, 'busProId' => $dateId, 'from' => $from, 'to' => $dateFrom->modify(sprintf('+%d days', $days))->format('Y-m-d'), 'type' => 'F'], 'hotel' => ['code' => $hotelCode, 'busProId' => crc32($hotelCode)], + 'selectionGroups' => $withExtras ? [ + ['busProId' => 7, 'label' => 'Leistungen Skipass', 'selections' => [['busProId' => 92, 'label' => '6 Tage Skipass']]], + ['busProId' => 71, 'label' => 'Leistungen Verpflegung', 'selections' => [['busProId' => 3, 'label' => 'Halbpension']]], + ['busProId' => 86, 'label' => 'Leistungen Programm', 'selections' => [['busProId' => 5, 'label' => 'E&P-Programm']]], + ] : [], + 'transportServices' => $withExtras ? [ + ['busProId' => 1, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Frei', 'direction' => 'HIN'], + ['busProId' => 2, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Frei', 'direction' => 'RUECK'], + ] : [ + ['busProId' => 3, 'subType' => 'BUS', 'label' => 'Bus', 'price' => 0, 'status' => 'Buchungsstop', 'direction' => 'HIN'], + ], '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'], @@ -178,8 +235,8 @@ class TripCatalogTest extends TestCase /** * @return array */ - private function room(int $price, int $available = 5, int $maxPax = 2, string $status = 'Frei'): array + private function room(int $price, int $available = 5, int $maxPax = 2, string $status = 'Frei', int $minPax = 1, string $code = 'DZ', string $label = 'Zimmer'): array { - return ['code' => 'DZ', 'label' => 'Zimmer', 'minPax' => 1, 'maxPax' => $maxPax, 'nights' => 7, 'price' => $price, 'available' => $available, 'status' => $status, 'boardCode' => 'HP']; + return ['code' => $code, 'label' => $label, 'minPax' => $minPax, 'maxPax' => $maxPax, 'nights' => 7, 'price' => $price, 'available' => $available, 'status' => $status, 'boardCode' => 'HP']; } } diff --git a/tests/Enum/RoomTypeTest.php b/tests/Enum/RoomTypeTest.php new file mode 100644 index 00000000..28cc3659 --- /dev/null +++ b/tests/Enum/RoomTypeTest.php @@ -0,0 +1,38 @@ + + */ + public static function rooms(): iterable + { + yield 'single room' => ['EZ', 'Einzelzimmer Dusche/WC', 1, 1, RoomType::ForOne]; + yield 'double room' => ['DZ', 'Doppelzimmer', 2, 1, RoomType::ForTwo]; + yield 'six-bed room' => ['6BZ', 'Sechsbettzimmer', 6, 4, RoomType::ForSix]; + yield 'nine and more' => ['MBZ', 'Gruppenraum', 12, 8, RoomType::ForNinePlus]; + yield 'shared bed by "Bett im"' => ['MBZET', 'Bett im Mehrbettzimmer Etagendusche/-WC', 1, 1, RoomType::WithOthers]; + yield 'shared bed by "mit anderen"' => ['APP', 'Apartment mit anderen Kunden', 4, 1, RoomType::WithOthers]; + yield 'marker in any case' => ['X', 'BETT IM LAGER', 1, 1, RoomType::WithOthers]; + yield 'minPax when maxPax is missing' => ['DZ', 'Doppelzimmer', null, 2, RoomType::ForTwo]; + yield 'no occupancy at all' => ['X', 'Unbekannt', 0, 0, RoomType::WithOthers]; + yield 'baby row is no room' => ['Baby', 'Babypreis für 0-2 Jährige', 1, 1, null]; + } + + #[DataProvider('rooms')] + public function testClassifiesByCapacityWithTheSharedBedAndBabyExceptions(?string $code, ?string $label, ?int $maxPax, ?int $minPax, ?RoomType $expected): void + { + self::assertSame($expected, RoomType::classify($code, $label, $maxPax, $minPax)); + } +}