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
+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');
|
||||
|
||||
Reference in new issue
Block a user