825 lines
33 KiB
PHP
825 lines
33 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Chatbot;
|
|
|
|
use App\BusProNet\Utility\DateCodeUtility;
|
|
use App\BusProNet\XmlLoader\HotelLoader;
|
|
use App\Chatbot\Search\ContentSearchQuery;
|
|
use App\Chatbot\Search\NameQuery;
|
|
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;
|
|
use App\Service\ProductExportBuilder;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\Clock\ClockInterface;
|
|
|
|
/**
|
|
* Trips as the chatbot sees them: TYPO3's editorial product joined to our dates, rooms,
|
|
* prices and pickups from the product export.
|
|
*
|
|
* The join is on the product code; several TYPO3 products may share one, and each is its
|
|
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
|
|
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
|
|
* `code` it is. BusPro's own hotel names (hotel.xml) are internal wording, so they are
|
|
* only searched, and shown just where TYPO3 has no hotel for a code.
|
|
*
|
|
* search() mirrors the website's travel search, get() details one trip. Free text is read
|
|
* as a code or a name before it is looked up in the page texts, see resolveQuery(). 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_FREE = 'Frei';
|
|
|
|
private const DATES_WITH_PICKUPS = 3;
|
|
private const BODY_LIMIT = 20000;
|
|
private const HOTEL_BODY_LIMIT = 8000;
|
|
|
|
private const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept'];
|
|
|
|
public function __construct(
|
|
private readonly ProductExportBuilder $exportBuilder,
|
|
private readonly ContentDocumentRepository $repository,
|
|
private readonly HotelLoader $hotelLoader,
|
|
private readonly ClockInterface $clock,
|
|
private readonly LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$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<string, mixed>|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<string, ContentDocument>,
|
|
* productsByCode: array<string, list<ContentDocument>>,
|
|
* hotelsByCode: array<string, list<ContentDocument>>,
|
|
* rowsByCode: array<string, list<array<string, mixed>>>,
|
|
* busProHotels: array<int, array{code: string, name: ?string}>,
|
|
* }
|
|
*/
|
|
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<string, mixed> $catalog
|
|
*
|
|
* @return list<ContentDocument>
|
|
*/
|
|
private function candidateProducts(array $catalog, TripCriteria $criteria): array
|
|
{
|
|
/** @var array<string, ContentDocument> $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<string, ContentDocument> $records
|
|
* @param list<string> $types
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
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<string, mixed> $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<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: ?string}|null
|
|
*/
|
|
private function resolveQuery(array $catalog, string $text): ?array
|
|
{
|
|
/** @var array<string, ContentDocument> $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<string, mixed> $catalog
|
|
*
|
|
* @return array<string, array<int, true>>|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<string, mixed> $catalog
|
|
*
|
|
* @return array<string, array<int, true>>|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<string, ContentDocument> $records
|
|
* @param array<string, array<int, true>> $matched by type and uid
|
|
*
|
|
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, 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<string, mixed> $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<string, mixed> $catalog
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $catalog
|
|
* @param list<string> $hotelCodes normalized codes of the hotels with dates
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $catalog
|
|
* @param list<array<string, mixed>> $rows sorted by date
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $row
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $catalog
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
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<string, mixed> $catalog
|
|
* @param array<string, mixed> $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)) : '';
|
|
}
|
|
}
|