351 lines
13 KiB
PHP
351 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\BusProNet\XmlLoader;
|
|
|
|
use App\BusProNet\Model\BaseData;
|
|
use App\BusProNet\Model\MutableData;
|
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
|
use App\BusProNet\Model\Travel;
|
|
use App\BusProNet\Utility\DateCodeUtility;
|
|
use App\BusProNet\XmlCrawlerFactory;
|
|
use App\BusProNet\XmlParser\TravelParser;
|
|
use App\Exception\HotelNotInTravelException;
|
|
use App\Exception\TravelNotFoundException;
|
|
use League\Flysystem\FilesystemException;
|
|
use League\Flysystem\FilesystemOperator;
|
|
use League\Flysystem\StorageAttributes;
|
|
use Psr\Cache\InvalidArgumentException;
|
|
use Symfony\Component\DomCrawler\Crawler;
|
|
use Symfony\Contracts\Cache\CacheInterface;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
|
|
/**
|
|
* XML loader for travel data from BusProNet XML exports.
|
|
*
|
|
* This loader processes XML files containing travel information including dates,
|
|
* hotels, services, rooms, and pricing. It provides caching and mapping functionality
|
|
* to efficiently load and parse travel data from XML exports.
|
|
*/
|
|
class TravelLoader extends AbstractLoader
|
|
{
|
|
/**
|
|
* @param HotelLoader $hotelDataLoader Hotel data loader for hotel information
|
|
* @param TravelParser $travelParser Parser for XML travel data
|
|
* @param string $travelInfoBaseUrl Base URL for travel information pages
|
|
* @param CacheInterface $cache Cache interface for performance optimization
|
|
* @param FilesystemOperator $xmlExport Filesystem operator for XML file access
|
|
*/
|
|
public function __construct(
|
|
private readonly HotelLoader $hotelDataLoader,
|
|
private readonly TravelParser $travelParser,
|
|
private readonly string $travelInfoBaseUrl,
|
|
CacheInterface $cache,
|
|
FilesystemOperator $xmlExport,
|
|
) {
|
|
parent::__construct($cache, $xmlExport);
|
|
}
|
|
|
|
/**
|
|
* Generate a cached mapping of travel IDs to their file locations and metadata.
|
|
*
|
|
* Scans all XML files starting with 'Ziel_' and extracts travel information
|
|
* including ID, code, label, dates, and associated hotels. The mapping is
|
|
* cached for 3 hours to improve performance.
|
|
*
|
|
* @return array<int, array<string, mixed>> Mapping of travel IDs to their metadata and file paths
|
|
*/
|
|
public function generateFilesMap(): array
|
|
{
|
|
try {
|
|
return $this->cache->get('bpn_travels_mapping', function (ItemInterface $item) {
|
|
$item->expiresAfter(3 * 60 * 60);
|
|
$item->tag(['xml-sync']);
|
|
|
|
$xmlFiles = $this
|
|
->xmlExport
|
|
->listContents('.')
|
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
|
|
|
|
$mapping = [];
|
|
$hotels = $this->hotelDataLoader->loadAll();
|
|
|
|
foreach ($xmlFiles as $file) {
|
|
$xml = $this->xmlExport->read($file->path());
|
|
|
|
$crawler = XmlCrawlerFactory::create($xml);
|
|
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
|
|
|
|
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
|
|
$travelId = (int) $node->attr('idbuspro');
|
|
|
|
$mapping[$travelId] = [
|
|
'id' => $travelId,
|
|
'code' => $node->attr('code'),
|
|
'label' => $this->getStringOrNullValue($node->filterXPath('//text')),
|
|
'dateFrom' => $this->stringToDate($node->attr('termin')),
|
|
'dateTo' => $this->stringToDate($node->attr('bis')),
|
|
'hotels' => [],
|
|
'file' => $file->path(),
|
|
];
|
|
|
|
$node->filterXPath('//hotel')
|
|
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
|
|
|
$mapping[$travelId]['hotels'][$hotelId] = [
|
|
'id' => $hotelId,
|
|
'code' => $hotelNode->attr('code'),
|
|
'label' => $hotels[$hotelId]->name,
|
|
];
|
|
});
|
|
});
|
|
}
|
|
|
|
return $mapping;
|
|
});
|
|
} catch (InvalidArgumentException $e) {
|
|
throw new \RuntimeException('Cache key error in generateFilesMap', 0, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map a date code to its corresponding date ID.
|
|
*
|
|
* Uses the cached files mapping to find the date ID associated with
|
|
* the given date code. Returns null if no matching date is found.
|
|
*
|
|
* @param string $dateCode The date code to look up
|
|
*
|
|
* @return int|null The date ID or null if not found
|
|
*/
|
|
public function mapCodeToId(string $dateCode): ?int
|
|
{
|
|
$mapping = $this->generateFilesMap();
|
|
|
|
$dateCodes = array_column($mapping, 'code', 'id');
|
|
|
|
if (false === $dateId = array_search($dateCode, $dateCodes)) {
|
|
return null;
|
|
}
|
|
|
|
return $dateId;
|
|
}
|
|
|
|
/**
|
|
* Map a date ID to its corresponding product ID.
|
|
*
|
|
* Finds the product ID by looking up which XML file contains the given date ID.
|
|
* Since XML files are named with the product ID pattern (Ziel_x.xml), this method
|
|
* extracts the product ID from the file path.
|
|
*
|
|
* @param int $dateId The date/term ID to look up
|
|
*
|
|
* @return int|null The product ID or null if not found
|
|
*/
|
|
public function mapDateIdToProductId(int $dateId): ?int
|
|
{
|
|
$mapping = $this->generateFilesMap();
|
|
|
|
if (!isset($mapping[$dateId])) {
|
|
return null;
|
|
}
|
|
|
|
$filename = $mapping[$dateId]['file'];
|
|
|
|
// Extract product ID from filename (e.g., "Ziel_12345.xml" -> 12345)
|
|
if (preg_match('/Ziel_(\d+)\.xml$/', $filename, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Load a travel object by its ID and optional hotel ID.
|
|
*
|
|
* Retrieves travel data from XML exports. If filename is provided, loads directly
|
|
* from that file. Otherwise, uses the cached mapping to find the appropriate file.
|
|
* Validates that both travel and hotel (if specified) exist before attempting to parse.
|
|
*
|
|
* @param int $dateId The travel date ID to load
|
|
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
|
* @param string|null $filename Optional filename to load from directly
|
|
*
|
|
* @return Travel The loaded travel object
|
|
*
|
|
* @throws TravelNotFoundException When travel ID is not found
|
|
* @throws HotelNotInTravelException When hotel ID exists but not for this travel
|
|
* @throws \RuntimeException When cached mapping generation fails unexpectedly
|
|
*/
|
|
public function loadById(int $dateId, ?int $hotelId = null, ?string $filename = null): Travel
|
|
{
|
|
if (null !== $filename) {
|
|
return $this->loadXml($dateId, $hotelId, $filename);
|
|
}
|
|
|
|
$mapping = $this->generateFilesMap();
|
|
|
|
// Validate travel exists
|
|
if (false === isset($mapping[$dateId])) {
|
|
throw new TravelNotFoundException($dateId);
|
|
}
|
|
|
|
// Validate hotel exists in this travel if specified
|
|
if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) {
|
|
throw new HotelNotInTravelException($dateId, $hotelId);
|
|
}
|
|
|
|
$filename = $mapping[$dateId]['file'];
|
|
|
|
return $this->loadXml($dateId, $hotelId, $filename);
|
|
}
|
|
|
|
/**
|
|
* Load travel data from a specific XML file.
|
|
*
|
|
* Reads and parses XML file content to extract travel information for
|
|
* the specified travel ID and optional hotel ID.
|
|
*
|
|
* @param int $dateId The travel date ID to load
|
|
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
|
* @param string $filename The XML filename to load from
|
|
*
|
|
* @return Travel The loaded travel object
|
|
*
|
|
* @throws TravelNotFoundException When travel ID is not found in XML
|
|
* @throws HotelNotInTravelException When hotel ID is not found in travel XML
|
|
*/
|
|
private function loadXml(int $dateId, ?int $hotelId, string $filename): Travel
|
|
{
|
|
try {
|
|
$xml = $this->xmlExport->read($filename);
|
|
$crawler = XmlCrawlerFactory::create($xml);
|
|
|
|
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
|
|
|
|
if (0 === $travelNode->count()) {
|
|
throw new TravelNotFoundException($dateId);
|
|
}
|
|
|
|
// Validate hotel exists in travel XML if specified
|
|
if (null !== $hotelId) {
|
|
$hotelNode = $travelNode->filterXPath(sprintf('.//hotel[@idbuspro="%d"]', $hotelId));
|
|
if (0 === $hotelNode->count()) {
|
|
throw new HotelNotInTravelException($dateId, $hotelId);
|
|
}
|
|
}
|
|
|
|
return $this->travelParser->parse($travelNode->first(), $hotelId);
|
|
} catch (FilesystemException $e) {
|
|
throw new TravelNotFoundException($dateId, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply mutability settings to travel object.
|
|
*
|
|
* Updates the travel object's mutability flags based on the provided
|
|
* mutable data configuration. This controls which travel components
|
|
* can be modified during booking.
|
|
*
|
|
* @param Travel $travel The travel object to update
|
|
* @param BaseData $mutableData The mutability configuration data
|
|
*/
|
|
public function patchMutability(Travel $travel, BaseData $mutableData): void
|
|
{
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_ADDITIONAL_SERVICES)) {
|
|
$travel->additionalServicesMutable = $item->mutable;
|
|
}
|
|
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_TRANSPORTATION)) {
|
|
$travel->transportationServicesMutable = $item->mutable;
|
|
}
|
|
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_PICKUP)) {
|
|
$travel->pickupsMutable = $item->mutable;
|
|
}
|
|
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_ACCOMMODATION)) {
|
|
$travel->roomsMutable = $item->mutable;
|
|
}
|
|
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_PARTICIPANT_COUNT)) {
|
|
$travel->participantCountMutable = $item->mutable;
|
|
}
|
|
|
|
if (null !== $item = $mutableData->getItemByKey(MutableData::CATEGORY_PARTICIPANT_DATA)) {
|
|
$travel->participantDataMutable = $item->mutable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply availability data to travel services.
|
|
*
|
|
* Updates the availability status of additional and transportation
|
|
* services, and the allowed booking status, based on the provided
|
|
* availability data.
|
|
*
|
|
* @param Travel $travel The travel object to update
|
|
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
|
*/
|
|
public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void
|
|
{
|
|
$serviceAvailabilities = $availabilities->getServices();
|
|
|
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
|
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
|
$service->available = $serviceAvailabilities[$service->id]->available;
|
|
}
|
|
}
|
|
|
|
// Patch travel-level booking status from availability response
|
|
if ([] !== $availabilities->allowedBookingStatus) {
|
|
$travel->allowedBookingStatus = $availabilities->allowedBookingStatus;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhance booking data with travel information and URLs.
|
|
*
|
|
* Loads travel data for each booking and generates travel info URLs
|
|
* for bookings that are within 5 days of their travel date.
|
|
*
|
|
* @param BaseData $bookings The booking data to enhance
|
|
*/
|
|
public function patchBookings(BaseData $bookings): void
|
|
{
|
|
$travelCodeUtility = new DateCodeUtility();
|
|
|
|
foreach ($bookings->getItems() as $booking) {
|
|
if (null === $travelId = $booking->dateId) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$travelData = $this->loadById($travelId);
|
|
$booking->travelData = $travelData;
|
|
} catch (TravelNotFoundException) {
|
|
// Travel data not available (likely past travel) - skip patching
|
|
continue;
|
|
}
|
|
|
|
if (null === $travelData->dateFrom) {
|
|
continue;
|
|
}
|
|
|
|
$now = new \DateTimeImmutable();
|
|
$travelDate = $travelData->dateFrom;
|
|
$travelCode = $travelData->code;
|
|
|
|
// generate url to travel info page five days before travel begins
|
|
if ($travelDate->modify('-5 days') < $now) {
|
|
$booking->travelInfoUrl = sprintf(
|
|
'%s/%s',
|
|
rtrim($this->travelInfoBaseUrl, '/'),
|
|
$travelCodeUtility->sanitize($travelCode)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|