>, total: int, dataAsOf: string} */ public function search(TripCriteria $criteria): array { $catalog = $this->load(); $today = $this->today(); $named = null === $criteria->query ? null : $this->resolveQuery($catalog, $criteria->query); $results = []; foreach ($this->candidateProducts($catalog, $criteria) as $product) { $header = null; foreach ($catalog['rowsByCode'][$this->normalize($product->getData()['code'] ?? null)] ?? [] as $row) { if ($row['date']['from'] < $today) { continue; } $hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null; $hotelData = $hotel?->getData() ?? []; if (null !== $named && !isset($named['products'][$product->getUid()]) && !isset($named['hotels'][$hotel?->getUid() ?? 0]) && !isset($named['busProHotels'][$row['hotel']['busProId'] ?? 0]) && (null === $named['dateCode'] || $named['dateCode'] !== $this->normalize($row['date']['code'] ?? null))) { continue; } $facts = TravelDateFacts::fromExportRow($row); 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' => $this->hotelName($catalog, $hotel, $row), 'hotelCode' => $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'], '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'], ]; } } 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), 'total' => count($results), 'dataAsOf' => $this->dataAsOf($catalog['export']), ]; } /** * Everything about one trip, or null for a code no published product carries. * * Takes a product code or a date code. A date code is what customers read on their * booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to * that date, shown even when it has already started. * * Without $editorial the product and hotel page texts are left out, and only what is * structured remains: names, links, hotel facts and everything from the booking system. * * @return array|null */ public function get(string $code, ?string $hotelCode = null, bool $editorial = true): ?array { $catalog = $this->load(); $code = (new DateCodeUtility())->sanitize(trim($code)); $dateCode = null; if (!isset($catalog['productsByCode'][$code])) { foreach ($catalog['export']->rows as $row) { if ($this->normalize($row['date']['code'] ?? null) === $code) { $dateCode = $code; $code = $this->normalize($row['product']['code'] ?? null); break; } } } $products = $catalog['productsByCode'][$code] ?? []; if ([] === $products) { return null; } usort($products, static fn (ContentDocument $a, ContentDocument $b): int => [null === $a->getUrl(), $a->getUid()] <=> [null === $b->getUrl(), $b->getUid()]); $product = $products[0]; $today = $this->today(); $rows = array_values(array_filter( $catalog['rowsByCode'][$code] ?? [], fn (array $row): bool => null === $dateCode ? $row['date']['from'] >= $today : $this->normalize($row['date']['code'] ?? null) === $dateCode, )); if (null !== $hotelCode && '' !== trim($hotelCode)) { $wanted = $this->hotelCodesFor($catalog, $hotelCode); $rows = array_values(array_filter($rows, fn (array $row): bool => in_array($this->normalize($row['hotel']['code']), $wanted, true))); } usort($rows, static fn (array $a, array $b): int => [$a['date']['from'], $a['hotel']['code']] <=> [$b['date']['from'], $b['hotel']['code']]); $hotelCodes = array_values(array_unique(array_map(fn (array $row): string => $this->normalize($row['hotel']['code']), $rows))); return $this->tripHeader($catalog, $product) + [ 'alsoPublishedAt' => array_values(array_map( static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()], array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()), )), ] + ($editorial ? ['description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT)] : []) + [ 'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product, $editorial), 'dates' => $this->dates($catalog, $rows), 'dataAsOf' => $this->dataAsOf($catalog['export']), ]; } /** * Logs the codes the join cannot place, on both sides. The same gaps the website's * product import tolerates, worth a periodic look. */ public function logUnmatchedCodes(): void { $catalog = $this->load(); $exportProducts = array_keys($catalog['rowsByCode']); $contentProducts = array_keys($catalog['productsByCode']); $exportHotels = []; foreach ($catalog['export']->rows as $row) { $exportHotels[$this->normalize($row['hotel']['code'] ?? null)] = true; } unset($exportHotels['']); $ambiguous = []; foreach ($catalog['hotelsByCode'] as $hotelCode => $hotels) { if (count($hotels) > 1) { $ambiguous[$hotelCode] = array_map(static fn (ContentDocument $hotel): int => $hotel->getUid(), $hotels); } } $context = [ 'exportProductsWithoutContent' => array_values(array_diff($exportProducts, $contentProducts)), 'contentProductsWithoutDates' => array_values(array_diff($contentProducts, $exportProducts)), 'exportHotelsWithoutContent' => array_values(array_diff(array_keys($exportHotels), array_keys($catalog['hotelsByCode']))), 'ambiguousHotelCodes' => $ambiguous, ]; $this->logger->info('Content feed: unmatched codes', $context + [ 'counts' => array_map('count', $context), ]); } /** * @return array{ * export: Export, * records: array, * productsByCode: array>, * hotelsByCode: array>, * rowsByCode: array>>, * busProHotels: array, * } */ private function load(): array { $export = $this->exportBuilder->build(); $records = $this->repository->findRecords(self::RECORD_TYPES, self::LANGUAGE); $productsByCode = []; $hotelsByCode = []; foreach ($records as $record) { $data = $record->getData() ?? []; if ('product' === $record->getType() && '' !== ($code = $this->normalize($data['code'] ?? null))) { $productsByCode[$code][] = $record; } if ('hotel' === $record->getType()) { $codes = is_array($data['codes'] ?? null) ? $data['codes'] : []; $codes[] = $data['code'] ?? null; foreach (array_unique(array_map($this->normalize(...), $codes)) as $code) { if ('' !== $code) { $hotelsByCode[$code][] = $record; } } } } // A code that is one hotel's own and another's alias resolves to the owner. foreach ($hotelsByCode as $code => $hotels) { usort($hotels, fn (ContentDocument $a, ContentDocument $b): int => [$this->normalize($a->getData()['code'] ?? null) !== $code, $a->getUid()] <=> [$this->normalize($b->getData()['code'] ?? null) !== $code, $b->getUid()]); $hotelsByCode[$code] = $hotels; } $rowsByCode = []; $busProHotels = []; $masterData = $this->hotelLoader->loadAll(); foreach ($export->rows as $row) { $code = $this->normalize($row['product']['code'] ?? null); if ('' !== $code) { $rowsByCode[$code][] = $row; } $hotelId = (int) ($row['hotel']['busProId'] ?? 0); if (0 !== $hotelId && !isset($busProHotels[$hotelId])) { // BusPro breaks names over lines ("Vacanceole\nRésidence L'Edelweiss"). $name = trim((string) preg_replace('/\s+/u', ' ', isset($masterData[$hotelId]) ? (string) $masterData[$hotelId]->name : '')); $busProHotels[$hotelId] = ['code' => $this->normalize($row['hotel']['code'] ?? null), 'name' => '' === $name ? null : $name]; } } return [ 'export' => $export, 'records' => $records, 'productsByCode' => $productsByCode, 'hotelsByCode' => $hotelsByCode, 'rowsByCode' => $rowsByCode, 'busProHotels' => $busProHotels, ]; } /** * Published products narrowed by everything the content index can resolve: site, day * trip, concept and destination. Free text is applied per travel date, since it may * name one hotel of a trip. * * @param array $catalog * * @return list */ private function candidateProducts(array $catalog, TripCriteria $criteria): array { /** @var array $records */ $records = $catalog['records']; $conceptUids = null === $criteria->concept ? null : $this->matchingUids($records, ['concept'], $criteria->concept, matchCode: true); $countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true); $regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region); $cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region); $products = []; foreach ($records as $record) { if ('product' !== $record->getType() || null === $record->getUrl()) { continue; } $data = $record->getData() ?? []; // Closed group trips and staff trainings are not offered; get() still answers // someone who names their code. if (false === ($data['bookable'] ?? null) || $record->isHiddenFromSearch() || (null !== $criteria->site && $record->getSite() !== $criteria->site) || (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip) || (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true)) || (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))) { continue; } if (null !== $regionUids) { $productRegions = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []); if ([] === array_intersect($productRegions, $regionUids) && !in_array($data['cityUid'] ?? null, $cityUids ?? [], true)) { continue; } } $products[] = $record; } return $products; } /** * Uids of the records of the given types whose name contains the text, or whose code * equals it. * * @param array $records * @param list $types * * @return list */ private function matchingUids(array $records, array $types, string $text, bool $matchCode = false): array { $needle = mb_strtolower(trim($text)); $uids = []; foreach ($records as $record) { if (!in_array($record->getType(), $types, true)) { continue; } $name = mb_strtolower($record->getTitle()); $code = $this->normalize($record->getData()['code'] ?? null); if (('' !== $name && (str_contains($name, $needle) || str_contains($needle, $name))) || ($matchCode && '' !== $code && $code === $this->normalize($text))) { $uids[] = $record->getUid(); } } return $uids; } /** * What a free-text query asks for: whole trips, single hotels of trips, or one travel * date. Null when it restricts nothing. * * A code or a name is taken at its word, so naming a trip or hotel returns that one and * not every page that mentions it. In order, stopping at the first that finds anything: * a product, hotel or date code; names holding every word of the query; names the query * spells out among other words; and only then the page texts. * * @param array $catalog * * Hotels are known twice: by their TYPO3 record and by BusPro's own name and code, which * also covers a hotel TYPO3 has no record for * * @return array{products: array, hotels: array, busProHotels: array, dateCode: ?string}|null */ private function resolveQuery(array $catalog, string $text): ?array { /** @var array $records */ $records = $catalog['records']; $matched = $this->recordsWithCode($catalog, $text) ?? $this->recordsNamed($catalog, $text); if (null !== $matched) { return $this->tripsOf($records, $matched); } $dateCode = (new DateCodeUtility())->sanitize(trim($text)); foreach ($catalog['export']->rows as $row) { if ($this->normalize($row['date']['code'] ?? null) === $dateCode) { return ['products' => [], 'hotels' => [], 'busProHotels' => [], 'dateCode' => $dateCode]; } } $terms = ContentSearchQuery::terms($text); if ([] === $terms) { return null; } // Every term first: "Sauna Whirlpool" means both. A question rarely uses the page's // own words for everything, so any term is the fallback. foreach (count($terms) > 1 ? [true, false] : [false] as $allTerms) { $matched = []; foreach ($this->repository->matchRecords((string) ContentSearchQuery::booleanQuery($text, $allTerms), self::RECORD_TYPES, self::LANGUAGE) as $hit) { $matched[$hit['type']][$hit['uid']] = true; } if ([] !== $matched) { break; } } return $this->tripsOf($records, $matched); } /** * The products and the hotel carrying the text as their code, hotel aliases included. * Matches are keyed by record type, BusPro's hotels as `busProHotel` by their id. * * @param array $catalog * * @return array>|null */ private function recordsWithCode(array $catalog, string $text): ?array { $code = (new DateCodeUtility())->sanitize(trim($text)); $matched = []; foreach ($catalog['productsByCode'][$code] ?? [] as $product) { $matched['product'][$product->getUid()] = true; } if (isset($catalog['hotelsByCode'][$code][0])) { $matched['hotel'][$catalog['hotelsByCode'][$code][0]->getUid()] = true; } foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) { if ($busProHotel['code'] === $code) { $matched['busProHotel'][$hotelId] = true; } } return [] === $matched ? null : $matched; } /** * The records the text names. A name holding every word of the query wins: "Lederer" * is "Sportclub Lederer". Otherwise the query may spell a name out among other words, * and there the most specific kind wins, so that "Sportclub Lederer Saalbach" is the * hotel and not everything in Saalbach. * * @param array $catalog * * @return array>|null */ private function recordsNamed(array $catalog, string $text): ?array { $query = NameQuery::tokens($text); $names = []; foreach ($catalog['records'] as $record) { $names[] = [$record->getType(), $record->getUid(), $record->getTitle()]; } foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) { $names[] = ['busProHotel', $hotelId, $busProHotel['name'] ?? '']; } $within = []; $contained = []; foreach ($names as [$type, $id, $title]) { $name = NameQuery::tokens($title); if (NameQuery::within($query, $name)) { $within[$type][$id] = true; } elseif (NameQuery::contains($query, $name)) { $contained[$type][$id] = true; } } if ([] !== $within) { return $within; } foreach ([['product', 'hotel', 'busProHotel'], ['city'], ['region'], ['country', 'concept']] as $types) { $matched = array_intersect_key($contained, array_flip($types)); if ([] !== $matched) { return $matched; } } return null; } /** * Matched records as trips: a product, destination or concept stands for the whole * trips, a hotel only for itself, since a trip may have many. * * @param array $records * @param array> $matched by type and uid * * @return array{products: array, hotels: array, busProHotels: array, dateCode: null} */ private function tripsOf(array $records, array $matched): array { $products = []; foreach ($records as $record) { if ('product' !== $record->getType()) { continue; } $data = $record->getData() ?? []; $regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []); if (isset($matched['product'][$record->getUid()]) || [] !== array_intersect($regionUids, array_keys($matched['region'] ?? [])) || isset($matched['country'][$data['countryUid'] ?? -1]) || isset($matched['city'][$data['cityUid'] ?? -1]) || isset($matched['concept'][$data['conceptUid'] ?? -1])) { $products[$record->getUid()] = true; } } return ['products' => $products, 'hotels' => $matched['hotel'] ?? [], 'busProHotels' => $matched['busProHotel'] ?? [], 'dateCode' => null]; } /** * 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. * * @param array $row */ private function matches(array $row, TravelDateFacts $facts, ?HotelType $hotelType, TripCriteria $criteria): bool { if (!$facts->isBookable()) { return false; } 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; } 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 (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 (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); } /** * @param array $catalog * * @return array */ private function tripHeader(array $catalog, ContentDocument $product): array { $data = $product->getData() ?? []; $name = fn (string $type, mixed $uid): ?string => is_int($uid) ? ($catalog['records'][$type.':'.$uid] ?? null)?->getTitle() : null; return [ 'code' => $data['code'] ?? null, 'name' => $product->getTitle(), 'url' => $product->getUrl(), 'site' => $product->getSite(), 'destination' => array_filter([ 'country' => $name('country', $data['countryUid'] ?? null), 'region' => $name('region', $data['regionUid'] ?? null), 'city' => $name('city', $data['cityUid'] ?? null), ]), 'concept' => $name('concept', $data['conceptUid'] ?? null), 'daytrip' => (bool) ($data['daytrip'] ?? false), ]; } /** * @param array $catalog * @param list $hotelCodes normalized codes of the hotels with dates * * @return list> */ private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product, bool $editorial): array { $hotels = []; foreach ($hotelCodes as $code) { $hotel = $catalog['hotelsByCode'][$code][0] ?? null; if (null !== $hotel) { $hotels[$hotel->getUid()] = $hotel; } } // A trip without upcoming dates still describes its hotels. One whose dates are at // a hotel the website does not know has none to describe. if ([] === $hotelCodes) { foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) { $hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null; if (null !== $hotel) { $hotels[$hotel->getUid()] = $hotel; } } } return array_values(array_map(fn (ContentDocument $hotel): array => [ 'code' => $hotel->getData()['code'] ?? null, 'name' => $hotel->getTitle(), 'url' => $hotel->getUrl(), 'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [], ] + ($editorial ? ['description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT)] : []), $hotels)); } /** * Upcoming dates with their bookable rooms per hotel. Pickups for the first few only: * they rarely differ between dates and would otherwise dominate the answer. * * @param array $catalog * @param list> $rows sorted by date * * @return list> */ private function dates(array $catalog, array $rows): array { $dates = []; foreach ($rows as $row) { $dateId = $row['date']['busProId']; $hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'])][0] ?? null; $rooms = []; foreach ($row['rooms'] ?? [] as $room) { if (TravelDateFacts::STATUS_BOOKING_STOP === ($room['status'] ?? null)) { continue; } $rooms[] = array_filter([ 'room' => $room['label'] ?? null, 'board' => $room['boardCode'] ?? null, 'nights' => $room['nights'] ?? null, 'price' => $room['price'] ?? null, 'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? TravelDateFacts::AVAILABILITY_FREE : TravelDateFacts::AVAILABILITY_ON_REQUEST, ], static fn (mixed $value): bool => null !== $value); } $dates[$dateId] ??= [ 'dateCode' => $row['date']['code'], 'dateFrom' => $row['date']['from'], 'dateTo' => $row['date']['to'], 'hotels' => [], 'pickups' => $this->pickups($row), ]; $dates[$dateId]['hotels'][] = [ 'hotel' => $this->hotelName($catalog, $hotel, $row), 'hotelCode' => $row['hotel']['code'], 'rooms' => $rooms, 'bookable' => [] !== $rooms, ]; } $dates = array_values($dates); foreach ($dates as $i => $date) { if ($i >= self::DATES_WITH_PICKUPS) { unset($dates[$i]['pickups']); } } return $dates; } /** * BusPro prices a boarding point per leg, so a customer travelling both ways pays the * surcharge twice. Both legs and their sum are spelled out, because a single figure * reads as the whole price. A leg listed without a price is billed nothing, so it is 0; * the return is absent only where the point is not served on the way back. * * @param array $row * * @return list> */ private function pickups(array $row): array { $pickups = []; foreach ($row['pickups'] ?? [] as $pickup) { if (0 === ($pickup['busProId'] ?? 0) || null === ($pickup['city'] ?? null)) { continue; } $outbound = (float) ($pickup['price'] ?? 0); $return = ($pickup['returnLeg'] ?? false) ? (float) ($pickup['returnPrice'] ?? 0) : null; $pickups[] = array_filter([ 'city' => $pickup['city'], 'place' => $pickup['street'] ?? null, // A clock reading without an offset, as BusPro states it. 'departure' => is_string($pickup['datetime'] ?? null) ? str_replace('T', ' ', substr($pickup['datetime'], 0, 16)) : null, 'surchargeOutbound' => $outbound, 'surchargeReturn' => $return, 'surchargeRoundTrip' => null !== $return ? round($outbound + $return, 2) : null, ], static fn (mixed $value): bool => null !== $value); } return $pickups; } /** * Normalized codes of the hotel a code names: all its own codes when TYPO3 knows the * hotel, else just the code. * * @param array $catalog * * @return list */ private function hotelCodesFor(array $catalog, string $hotelCode): array { $hotel = $catalog['hotelsByCode'][$this->normalize($hotelCode)][0] ?? null; if (null === $hotel) { return [$this->normalize($hotelCode)]; } $codes = is_array($hotel->getData()['codes'] ?? null) ? $hotel->getData()['codes'] : []; $codes[] = $hotel->getData()['code'] ?? null; return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes)))); } /** * The website's name for the hotel of a travel date, else BusPro's, else its code. * * @param array $catalog * @param array $row */ private function hotelName(array $catalog, ?ContentDocument $hotel, array $row): string { return $hotel?->getTitle() ?? $catalog['busProHotels'][$row['hotel']['busProId'] ?? 0]['name'] ?? (string) $row['hotel']['code']; } private function dataAsOf(Export $export): string { return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c'); } private function truncate(string $text, int $limit): string { if (mb_strlen($text) <= $limit) { return $text; } return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]"; } private function today(): string { return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d'); } private function normalize(mixed $code): string { return is_string($code) ? strtoupper(trim($code)) : ''; } }