616 lines
21 KiB
PHP
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();
|
|
}
|
|
}
|
|
}
|