Files
myep/src/Service/TravelDataService.php
T

687 lines
24 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\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException;
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 both local XML files and remote API.
*
* This service provides a unified interface for accessing travel data regardless of source,
* supporting automatic fallback between local XML files and remote API calls. It handles caching,
* error recovery, and data enrichment for both data sources.
*/
class TravelDataService
{
public const SOURCE_LOCAL = 'local';
public const SOURCE_REMOTE = 'remote';
public function __construct(
private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly bool $preferRemote = false,
private readonly bool $enableFallback = true,
) {
}
/**
* 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. Handles caching and enrichment
* of data from both sources.
*
* @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
* @param bool $enableCache Whether to use caching for this request
*
* @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,
bool $enableCache = true,
): ?Travel {
$preferRemote = $preferRemote ?? $this->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<string, bool> 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<int, array<string, mixed>> 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<Insurance> $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];
}
}
}
}
}