> 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 = new Crawler($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 = new Crawler($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) ); } } } }