feat: turn search_trips into the website's travel search

This commit is contained in:
fromme committed 2026-10-08 13:14:12 +02:00
1 parent bfdedefae3
commit 4ab2714bde
12 files changed
+693 -151

No files matched your search

+85 -102
View File
@@ -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');