Files
myep/src/Service/TravelDataProvider.php
T

616 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Unified service for retrieving travel data from local persisted sources and remote API.
*
* This service provides a unified interface for accessing travel data regardless of source,
* supporting automatic fallback between local persisted data and remote API calls. Local reads
* prefer snapshots for performance and refresh those snapshots from XML during sync. It handles
* caching, error recovery, and data enrichment for both data sources.
*
* Mapping/lookup operations are delegated to TravelIndex.
* Travel enrichment (XML details, insurances) is delegated to TravelEnricher.
*/
class TravelDataProvider
{
public const string SOURCE_LOCAL = 'local';
public const string SOURCE_REMOTE = 'remote';
private const int AVAILABILITY_CACHE_TTL = 600;
private const int MUTABILITY_CACHE_TTL = 300;
public function __construct(
private readonly TravelLoader $travelLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly TravelSnapshotManager $travelSnapshotService,
private readonly TravelIndex $travelLookupService,
private readonly TravelEnricher $travelEnrichmentService,
private readonly bool $preferRemote = false,
private readonly bool $enableFallback = true,
) {
}
// -------------------------------------------------------------------------
// Travel loading
// -------------------------------------------------------------------------
/**
* Retrieve travel data with automatic source selection and fallback.
*
* Attempts to load travel data from the preferred source first, then falls back
* to the alternative source if the primary fails.
*
* @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 for this call
*
* @return Travel|null The travel data or null if not found in any source
*/
public function getTravelData(
int $dateId,
?int $hotelId = null,
?bool $preferRemote = null,
): ?Travel {
return $this->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<int|string, array{hotels: array<int|string, mixed>}> $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<string, bool> 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<int, array<string, mixed>> 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();
}
}
}