Files
myep/src/Chatbot/TripCatalog.php
T

654 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Chatbot;
use App\Chatbot\Search\ContentSearchQuery;
use App\Entity\ContentDocument;
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.
*
* 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.
*/
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;
private const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept'];
public function __construct(
private readonly ProductExportBuilder $exportBuilder,
private readonly ContentDocumentRepository $repository,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
}
/**
* @return array{results: list<array<string, mixed>>, 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 ($catalog['rowsByCode'][$this->normalize($data['code'] ?? null)] ?? [] as $row) {
$summary = $this->summarize($row);
if (!$this->qualifies($row, $summary, $criteria, $today)) {
continue;
}
$dateId = $row['date']['busProId'];
$hotelCodes[$this->normalize($row['hotel']['code'])] = true;
$current = $dates[$dateId] ?? null;
$dates[$dateId] = [
'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'],
];
}
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']]);
return ['results' => array_slice($results, 0, $criteria->limit), 'dataAsOf' => $this->dataAsOf($catalog['export'])];
}
/**
* Everything about one trip, or null for a code no published product carries.
*
* @return array<string, mixed>|null
*/
public function get(string $code, ?string $hotelCode = null): ?array
{
$catalog = $this->load();
$products = $catalog['productsByCode'][$this->normalize($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'][$this->normalize($code)] ?? [],
fn (array $row): bool => $row['date']['from'] >= $today,
));
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()),
)),
'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT),
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product),
'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>>>,
* }
*/
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 = [];
foreach ($export->rows as $row) {
$code = $this->normalize($row['product']['code'] ?? null);
if ('' !== $code) {
$rowsByCode[$code][] = $row;
}
}
return [
'export' => $export,
'records' => $records,
'productsByCode' => $productsByCode,
'hotelsByCode' => $hotelsByCode,
'rowsByCode' => $rowsByCode,
];
}
/**
* Published products narrowed by everything the content index can resolve: site, day
* trip, concept, destination and free text.
*
* @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);
$queryUids = null === $criteria->query ? null : $this->productUidsMatchingText($records, $criteria->query);
$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))
|| (null !== $queryUids && !in_array($record->getUid(), $queryUids, 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;
}
/**
* Products reached by a full-text match on themselves or on a hotel, destination or
* concept they belong to.
*
* @param array<string, ContentDocument> $records
*
* @return list<int>
*/
private function productUidsMatchingText(array $records, string $text): array
{
$booleanQuery = ContentSearchQuery::booleanQuery($text);
if (null === $booleanQuery) {
return array_values(array_map(static fn (ContentDocument $record): int => $record->getUid(), array_filter($records, static fn (ContentDocument $record): bool => 'product' === $record->getType())));
}
$matched = [];
foreach ($this->repository->matchRecords($booleanQuery, self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
}
$uids = [];
foreach ($records as $record) {
if ('product' !== $record->getType()) {
continue;
}
$data = $record->getData() ?? [];
$hotelUids = is_array($data['hotelUids'] ?? null) ? $data['hotelUids'] : [];
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
$hit = isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($hotelUids, array_keys($matched['hotel'] ?? []))
|| [] !== 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]);
// Hotels also name their products, for products whose hotelUids miss them.
if (!$hit) {
foreach (array_keys($matched['hotel'] ?? []) as $hotelUid) {
$hotelProducts = ($records['hotel:'.$hotelUid] ?? null)?->getData()['productUids'] ?? [];
if (is_array($hotelProducts) && in_array($record->getUid(), $hotelProducts, true)) {
$hit = true;
break;
}
}
}
if ($hit) {
$uids[] = $record->getUid();
}
}
return $uids;
}
/**
* @param array<string, mixed> $row
*
* @return array{priceFrom: ?float, places: int, availability: ?string}
*/
private function summarize(array $row): array
{
$priceFrom = null;
$places = 0;
$availability = null;
foreach ($row['rooms'] ?? [] as $room) {
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
$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 (is_int($room['price'] ?? null) || is_float($room['price'] ?? null)) {
$priceFrom = $this->minPrice($priceFrom, (float) $room['price']);
}
}
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);
}
/**
* @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
*
* @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
*
* @return list<array<string, mixed>>
*/
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): 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.
if ([] === $hotels) {
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) : [],
'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 (self::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) ? self::AVAILABILITY_FREE : self::AVAILABILITY_ON_REQUEST,
], static fn (mixed $value): bool => null !== $value);
}
$dates[$dateId] ??= [
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'hotels' => [],
'pickups' => $this->pickups($row),
];
$dates[$dateId]['hotels'][] = [
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'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;
}
/**
* @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;
}
$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,
'surcharge' => $pickup['price'] ?? 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))));
}
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 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');
}
private function normalize(mixed $code): string
{
return is_string($code) ? strtoupper(trim($code)) : '';
}
}