loadTravelDataUncached($dateId, $hotelId, $preferRemote ?? $this->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, then persists a snapshot. * * @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 on unexpected failure * * @throws TravelNotFoundException When the travel date is not found * @throws HotelNotInTravelException When the hotel does not belong to this travel */ public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel { try { $travel = $this->travelLoader->loadById($dateId, $hotelId); } catch (TravelNotFoundException $e) { $this->logger->debug('Travel not found in XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); throw $e; } catch (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; } // Only persist a fully-enriched snapshot. A partial enrichment (failed pickup/hotel // loaders) must not overwrite an existing good snapshot with incomplete data. $enriched = $this->travelEnrichmentService->enrichFromXml($travel); if ($enriched) { try { $this->travelSnapshotService->upsertFromTravel($travel); } catch (\Throwable $e) { $this->logger->warning('Failed to persist travel snapshot after XML load', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); } } $this->logger->debug('Travel data loaded from XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'travelId' => $travel->id, ]); return $travel; } /** * Retrieve travel data from locally persisted sources. * * Runtime local reads prefer snapshots for performance and only fall back to XML * when no snapshot payload is available. Insurance data is always refreshed from * the XML loader to replace any stale snapshot values. */ public function getTravelDataFromLocal(int $dateId, ?int $hotelId = null): ?Travel { $travel = $this->getTravelDataFromSnapshot($dateId, $hotelId) ?? $this->getTravelDataFromXml($dateId, $hotelId); if (null !== $travel) { $this->travelEnrichmentService->patchInsurances($travel); } return $travel; } /** * 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 { $productId = $this->travelLookupService->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, ]); $this->travelEnrichmentService->patchInsurances($result); // Persist snapshot separately: a DB/serializer failure must not discard a // successfully fetched remote travel or make source=remote unreliable. try { $this->travelSnapshotService->upsertFromTravel($result); } catch (\Throwable $e) { $this->logger->warning('Failed to persist travel snapshot after API load', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); } return $result; } catch (ApiClientException $e) { $this->logger->error('Failed to load travel data from API', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); return null; } } /** * Syncs existing snapshots from fresh XML for all entries in the given file map. * * Iterates every date/hotel combination, calls getTravelDataFromXml() which * loads, enriches, and upserts the snapshot (hash-guarded; no write if unchanged). * * @param array}> $xmlFileMap * * @return array{processed:int,failed:int} */ public function syncSnapshotsFromXml(array $xmlFileMap, ?callable $onProgress = null): array { $processed = 0; $failed = 0; foreach ($xmlFileMap as $dateId => $entry) { foreach (array_keys($entry['hotels']) as $hotelId) { try { $travel = $this->getTravelDataFromXml((int) $dateId, $hotelId); } catch (\Throwable $e) { $this->logger->warning('Failed to sync snapshot from XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); ++$failed; if (null !== $onProgress) { ($onProgress)(); } continue; } if (null === $travel) { ++$failed; if (null !== $onProgress) { ($onProgress)(); } continue; } ++$processed; if (null !== $onProgress) { ($onProgress)(); } } } return ['processed' => $processed, 'failed' => $failed]; } // ------------------------------------------------------------------------- // Mutability // ------------------------------------------------------------------------- /** * 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: 5 minutes) * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) * * @return BaseData|null The mutability data or null if not available or error occurred */ public function getMutabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?BaseData { if ($cached) { return $this->fetchCached( sprintf('mutability_%d', $dateId), self::MUTABILITY_CACHE_TTL, fn () => $this->fetchMutabilityData($dateId), $forceRefresh, ); } return $this->fetchMutabilityData($dateId); } /** * Apply mutability data to travel services. * * @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, ]); } // ------------------------------------------------------------------------- // Availability // ------------------------------------------------------------------------- /** * 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) * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) * * @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred */ public function getAvailabilityData(int $dateId, bool $cached = false, bool $forceRefresh = false): ?ServiceAvailabilityResponse { if ($cached) { return $this->fetchCached( sprintf('availability_%d', $dateId), self::AVAILABILITY_CACHE_TTL, fn () => $this->fetchAvailabilityData($dateId), $forceRefresh, ); } return $this->fetchAvailabilityData($dateId); } /** * Apply availability data to travel services. * * @param Travel $travel The travel object to update * @param ServiceAvailabilityResponse $availabilities The availability data for services */ public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void { $this->travelLoader->patchAvailabilities($travel, $availabilities); $this->logger->debug('Successfully patched availability data', [ 'travelId' => $travel->id, ]); } /** * Enriches travel data with fresh availability information from the API. * * @param Travel $travel The travel object to enrich * @param bool $cached Whether to use cached availability data (default: true) */ public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void { $availabilities = $this->getAvailabilityData($travel->id, $cached); if (null !== $availabilities) { $this->patchAvailabilities($travel, $availabilities); } } // ------------------------------------------------------------------------- // Lookup delegates — public API preserved for backward compatibility // ------------------------------------------------------------------------- /** * Check if travel data exists in local persisted sources. * * @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 local sources */ public function existsLocally(int $dateId, ?int $hotelId = null): bool { return $this->travelLookupService->existsLocally($dateId, $hotelId); } /** * Get information about available data sources for a 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 $this->travelLookupService->getAvailableSources($dateId, $hotelId); } /** * Map date code to date ID. * * @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 { return $this->travelLookupService->mapDateCodeToId($dateCode); } /** * Map hotel code to hotel ID. * * @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 { return $this->travelLookupService->mapHotelCodeToId($hotelCode); } /** * Map date ID to product ID for API calls. * * @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 { return $this->travelLookupService->mapDateIdToProductId($dateId); } /** * Generate files mapping for available travel data. * * @return array> Array mapping of travel data files */ public function generateFilesMap(): array { return $this->travelLookupService->generateFilesMap(); } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- private function getTravelDataFromSnapshot(int $dateId, ?int $hotelId): ?Travel { try { $travel = $this->travelSnapshotService->loadTravel($dateId, $hotelId); } catch (\Throwable $e) { $this->logger->warning('Snapshot lookup failed, falling back to XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), ]); return null; } if (null === $travel) { return null; } $this->logger->debug('Travel data loaded from local snapshot', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'travelId' => $travel->id, ]); return $travel; } 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; $travel = $this->loadFromSource($dateId, $hotelId, $primarySource); if (null !== $travel) { return $travel; } 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; } private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel { return match ($source) { self::SOURCE_LOCAL => $this->getTravelDataFromLocal($dateId, $hotelId), self::SOURCE_REMOTE => $this->getTravelDataFromApi($dateId, $hotelId), default => null, }; } 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; } } private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse { 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; } } /** * Fetch a value from cache, computing it via $fetcher on a miss. * * On cache error the value is computed directly so callers are never blocked. * Pass $forceRefresh=true to delete the cached entry before fetching. * * @template T * * @param callable(): T $fetcher * * @return T|null */ private function fetchCached(string $cacheKey, int $ttl, callable $fetcher, bool $forceRefresh): mixed { try { if ($forceRefresh) { $this->cache->delete($cacheKey); } return $this->cache->get($cacheKey, function (ItemInterface $item) use ($ttl, $fetcher) { $item->expiresAfter($ttl); return $fetcher(); }); } catch (InvalidArgumentException $e) { $this->logger->error('Cache error', [ 'key' => $cacheKey, 'error' => $e->getMessage(), ]); return $fetcher(); } } }