preferRemote; $cacheKey = sprintf('travel_unified_%d_%d_%s', $dateId, $hotelId ?? 0, $preferRemote ? 'remote' : 'local'); if (!$enableCache) { return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote); } try { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $hotelId, $preferRemote) { $item->expiresAfter(300); // 5 minutes cache return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote); }); } catch (InvalidArgumentException $e) { $this->logger->error('Cache error in TravelDataService', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote); } } /** * Retrieve travel data specifically from XML files. * * Loads travel data from local XML files with full data enrichment including * hotel details and pickup information. * * @param int $dateId The travel date ID to retrieve * @param int|null $hotelId Optional hotel ID for specific hotel data * * @return Travel|null The travel data or null if not found in XML */ public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel { try { $travel = $this->travelLoader->loadById($dateId, $hotelId); $this->enrichTravelData($travel); $this->logger->debug('Travel data loaded from XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'travelId' => $travel->id, ]); return $travel; } catch (TravelNotFoundException $e) { $this->logger->debug('Travel not found in XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); throw $e; } catch (HotelNotFoundException|HotelNotInTravelException $e) { $this->logger->debug('Hotel not found in XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); throw $e; } catch (\Exception $e) { $this->logger->error('Failed to load travel data from XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); return null; } } /** * Retrieve travel data specifically from remote API. * * Loads travel data from the remote BusProNet API. Note that the API uses * product IDs rather than date IDs, so mapping is performed internally. * * @param int $dateId The travel date ID to retrieve * @param int|null $hotelId Optional hotel ID for specific hotel data * * @return Travel|null The travel data or null if not found via API */ public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel { try { // Map dateId to productId for API call $productId = $this->mapDateIdToProductId($dateId); if (null === $productId) { $this->logger->debug('Cannot map dateId to productId for API call', [ 'dateId' => $dateId, 'hotelId' => $hotelId, ]); return null; } $result = $this->apiClient->getTravelData($productId, $hotelId); if (!$result instanceof Travel) { $this->logger->debug('API returned non-travel result', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'productId' => $productId, 'resultType' => get_class($result), ]); return null; } $this->logger->debug('Travel data loaded from API', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'productId' => $productId, 'travelId' => $result->id, ]); return $result; } catch (ApiClientException $e) { $this->logger->error('Failed to load travel data from API', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); return null; } } /** * Check if travel data exists in XML files. * * Performs a lightweight check to determine if travel data exists in XML * files without loading the full travel object. * * @param int $dateId The travel date ID to check * @param int|null $hotelId Optional hotel ID for specific hotel data * * @return bool True if travel data exists in XML files */ public function existsInXml(int $dateId, ?int $hotelId = null): bool { $mapping = $this->generateFilesMap(); if (false === isset($mapping[$dateId])) { return false; } // If hotelId is specified, check if it exists in the travel's hotels if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) { return false; } return true; } /** * Get information about available data sources for a travel. * * Returns information about which data sources (local XML, remote API, or both) have * data available for the specified travel. * * @param int $dateId The travel date ID to check * @param int|null $hotelId Optional hotel ID for specific hotel data * * @return array Array with 'local' and 'remote' keys indicating availability */ public function getAvailableSources(int $dateId, ?int $hotelId = null): array { return [ static::SOURCE_LOCAL => $this->existsInXml($dateId, $hotelId), static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId), ]; } /** * Load travel data directly without caching. * * Internal method that handles the actual loading logic with fallback support. * Tries the preferred source first, then falls back to the alternative if enabled. * * @param int $dateId The travel date ID to retrieve * @param int|null $hotelId Optional hotel ID for specific hotel data * @param bool $preferRemote Whether to prefer remote API over XML * * @return Travel|null The travel data or null if not found */ private function loadTravelDataUncached(int $dateId, ?int $hotelId = null, bool $preferRemote = false): ?Travel { $primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL; $fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE; // Try primary source first $travel = $this->loadFromSource($dateId, $hotelId, $primarySource); if (null !== $travel) { return $travel; } // Try fallback source if enabled if (true === $this->enableFallback) { $this->logger->debug('Fallback to alternative source', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'primarySource' => $primarySource, 'fallbackSource' => $fallbackSource, ]); $travel = $this->loadFromSource($dateId, $hotelId, $fallbackSource); } return $travel; } /** * Load travel data from a specific source. * * Internal method that routes to the appropriate loader based on source type. * * @param int $dateId The travel date ID to retrieve * @param int|null $hotelId Optional hotel ID for specific hotel data * @param string $source The source type (SOURCE_LOCAL or SOURCE_REMOTE) * * @return Travel|null The travel data or null if not found */ private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel { return match ($source) { self::SOURCE_LOCAL => $this->getTravelDataFromXml($dateId, $hotelId), self::SOURCE_REMOTE => $this->getTravelDataFromApi($dateId, $hotelId), default => null, }; } /** * Map date code to date ID. * * Converts a date code string to its corresponding date ID using the * date loader's mapping functionality. * * @param string $dateCode The date code to map * * @return int|null The corresponding date ID or null if not found */ public function mapDateCodeToId(string $dateCode): ?int { try { $dateId = $this->travelLoader->mapCodeToId($dateCode); $this->logger->debug('Date code mapping', [ 'dateCode' => $dateCode, 'dateId' => $dateId, ]); return $dateId; } catch (\Exception $e) { $this->logger->error('Failed to map date code to ID', [ 'dateCode' => $dateCode, 'error' => $e->getMessage(), ]); return null; } } /** * Map hotel code to hotel ID. * * Converts a hotel code string to its corresponding hotel ID using the * hotel loader's mapping functionality. * * @param string $hotelCode The hotel code to map * * @return int|null The corresponding hotel ID or null if not found */ public function mapHotelCodeToId(string $hotelCode): ?int { try { $hotelId = $this->hotelLoader->mapCodeToId($hotelCode); $this->logger->debug('Hotel code mapping', [ 'hotelCode' => $hotelCode, 'hotelId' => $hotelId, ]); return $hotelId; } catch (\Exception $e) { $this->logger->error('Failed to map hotel code to ID', [ 'hotelCode' => $hotelCode, 'error' => $e->getMessage(), ]); return null; } } /** * Map date ID to product ID for API calls. * * Converts a date ID to its corresponding product ID for use with the * remote API. This method exposes the existing loader functionality * through the service layer. * * @param int $dateId The date ID to map * * @return int|null The corresponding product ID or null if not found */ public function mapDateIdToProductId(int $dateId): ?int { try { $productId = $this->travelLoader->mapDateIdToProductId($dateId); $this->logger->debug('Date ID to product ID mapping', [ 'dateId' => $dateId, 'productId' => $productId, ]); return $productId; } catch (\Exception $e) { $this->logger->error('Failed to map date ID to product ID', [ 'dateId' => $dateId, 'error' => $e->getMessage(), ]); return null; } } /** * Generate files mapping for available travel data. * * Creates a mapping of all available travel data files with their * corresponding travel and hotel information. This method exposes * the existing loader functionality through the service layer. * * @return array> Array mapping of travel data files */ public function generateFilesMap(): array { try { $mapping = $this->travelLoader->generateFilesMap(); $this->logger->debug('Generated files mapping', [ 'count' => count($mapping), ]); return $mapping; } catch (\Exception $e) { $this->logger->error('Failed to generate files mapping', [ 'error' => $e->getMessage(), ]); return []; } } /** * Fetch mutability data from API. * * Retrieves mutability configuration data from the API for a specific travel date. * Handles API errors and notification responses gracefully. * * @param int $dateId The travel date ID for API call * * @return BaseData|null The mutability data or null if not available or error occurred */ /** * Gets mutability data for a travel date. * * @param int $dateId The travel date ID for API call * @param bool $cached Whether to use cached data (default: true, TTL: 12 hours) * * @return BaseData|null The mutability data or null if not available or error occurred */ public function getMutabilityData(int $dateId, bool $cached = true): ?BaseData { if ($cached) { $cacheKey = sprintf('mutability_%d', $dateId); try { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) { // 12 hours TTL - mutability dates have date-only granularity $item->expiresAfter(43200); return $this->fetchMutabilityData($dateId); }); } catch (InvalidArgumentException $e) { $this->logger->error('Cache error in getMutabilityData', [ 'dateId' => $dateId, 'error' => $e->getMessage(), ]); // Fallback to direct API call return $this->fetchMutabilityData($dateId); } } return $this->fetchMutabilityData($dateId); } /** * Fetches mutability data directly from the API without caching. */ private function fetchMutabilityData(int $dateId): ?BaseData { try { $mutableData = $this->apiClient->getMutableData($dateId); if ($mutableData instanceof Notification) { $this->logger->warning('API returned notification for mutability data', [ 'dateId' => $dateId, 'message' => $mutableData->message, 'isError' => $mutableData->isError(), ]); return null; } $this->logger->debug('Successfully fetched mutability data', [ 'dateId' => $dateId, ]); return $mutableData; } catch (ApiClientException $e) { $this->logger->error('Failed to fetch mutability data from API', [ 'dateId' => $dateId, 'error' => $e->getMessage(), ]); return null; } } /** * Apply mutability data to travel services. * * Updates the mutability status of various travel services based on the * provided mutability configuration data. * * @param Travel $travel The travel object to update * @param BaseData $mutableData The mutability configuration data */ public function patchMutability(Travel $travel, BaseData $mutableData): void { $this->travelLoader->patchMutability($travel, $mutableData); $this->logger->debug('Successfully patched mutability data', [ 'travelId' => $travel->id, ]); } /** * Gets availability data for a travel date. * * @param int $dateId The travel date ID for API call * @param bool $cached Whether to use cached data (default: false, TTL when cached: 60 seconds) * @param int $ttl Cache TTL in seconds when $cached is true (default: 60 seconds) * * @return BaseData|null The availability data or null if not available or error occurred */ public function getAvailabilityData(int $dateId, bool $cached = false, int $ttl = 60): ?BaseData { if ($cached) { $cacheKey = sprintf('availability_%d', $dateId); try { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $ttl) { // Short TTL - availability is volatile and changes with bookings $item->expiresAfter($ttl); return $this->fetchAvailabilityData($dateId); }); } catch (InvalidArgumentException $e) { $this->logger->error('Cache error in getAvailabilityData', [ 'dateId' => $dateId, 'error' => $e->getMessage(), ]); // Fallback to direct API call return $this->fetchAvailabilityData($dateId); } } return $this->fetchAvailabilityData($dateId); } /** * Fetches availability data directly from the API without caching. */ private function fetchAvailabilityData(int $dateId): ?BaseData { try { $availabilities = $this->apiClient->getAvailabilities($dateId); if ($availabilities instanceof Notification) { $this->logger->warning('API returned notification for availability data', [ 'dateId' => $dateId, 'message' => $availabilities->message, 'isError' => $availabilities->isError(), ]); return null; } $this->logger->debug('Successfully fetched availability data', [ 'dateId' => $dateId, ]); return $availabilities; } catch (ApiClientException $e) { $this->logger->error('Failed to fetch availability data from API', [ 'dateId' => $dateId, 'error' => $e->getMessage(), ]); return null; } } /** * Apply availability data to travel services. * * Updates the availability status of additional and transportation * services based on the provided availability data. * * @param Travel $travel The travel object to update * @param BaseData $availabilities The availability data for services */ public function patchAvailabilities(Travel $travel, BaseData $availabilities): void { $this->travelLoader->patchAvailabilities($travel, $availabilities); $this->logger->debug('Successfully patched availability data', [ 'travelId' => $travel->id, ]); } /** * Enrich travel data with additional information. * * Adds hotel details and pickup information to travel data loaded from XML. * This enrichment is necessary for complete travel information. * * @param Travel $travel The travel object to enrich */ private function enrichTravelData(Travel $travel): void { try { $this->pickupLoader->patchPickupsDetails($travel); $this->hotelLoader->patchHotelDetails($travel); $this->patchInsuranceData($travel); } catch (\Exception $e) { $this->logger->warning('Failed to enrich travel data', [ 'travelId' => $travel->id, 'error' => $e->getMessage(), ]); } } /** * Adds insurance data to travel object. * * Loads all available insurances and adds them to the travel object. * This ensures insurance options are available for booking. * * @param Travel $travel The travel object to enrich with insurance data */ private function patchInsuranceData(Travel $travel): void { try { $insurances = $this->insuranceLoader->loadAll(); // Keep insurances indexed by ID for efficient lookups $travel->insurances = $insurances; // Hydrate package relationships after loading // Packages lose their containedInsurances during serialization, so rebuild them $this->hydrateInsurancePackageRelationships($travel->insurances); } catch (\Exception $e) { $this->logger->warning('Failed to load insurance data', [ 'travelId' => $travel->id, 'error' => $e->getMessage(), ]); } } /** * Reconstructs containedInsurances arrays for insurance packages. * * Insurance packages reference other insurances via containedInsuranceIds. * After deserialization, the containedInsurances array is empty due to * circular reference prevention. This method rebuilds those relationships. * * @param array $insurances All insurances including packages */ private function hydrateInsurancePackageRelationships(array $insurances): void { // Build lookup map of all insurances by ID (includes complementary insurances) $insuranceById = []; foreach ($insurances as $insurance) { $insuranceById[$insurance->id] = $insurance; } // Reconstruct containedInsurances for each package foreach ($insurances as $insurance) { if (!$insurance->package || empty($insurance->containedInsuranceIds)) { continue; } $insurance->containedInsurances = []; foreach ($insurance->containedInsuranceIds as $containedId) { if (isset($insuranceById[$containedId])) { $insurance->containedInsurances[] = $insuranceById[$containedId]; } } } } }