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
@@ -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)%'
|
||||
|
||||
+30
-5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Chatbot;
|
||||
|
||||
use App\Chatbot\TravelPeriods;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
|
||||
class TravelPeriodsTest extends TestCase
|
||||
{
|
||||
private TravelPeriods $periods;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Enum;
|
||||
|
||||
use App\Enum\RoomType;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Pins the classification to TYPO3's RoomTypeResolver, which the website search filters on.
|
||||
*/
|
||||
class RoomTypeTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{?string, ?string, ?int, ?int, ?RoomType}>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user