feat: persist travel snapshots and refresh extended availability

This commit is contained in:
Björn Fromme
2026-03-23 17:29:10 +01:00
parent c1ab6d8687
commit bc7fb794bf
31 changed files with 2328 additions and 91 deletions
+17
View File
@@ -12,6 +12,7 @@ use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher;
@@ -38,6 +39,7 @@ class ApiClient
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_BOOKING = 'BUCHUNG';
@@ -315,6 +317,21 @@ class ApiClient
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, $data);
}
/**
* @throws ApiClientException
*/
+36
View File
@@ -36,14 +36,50 @@ class Booking
public ?string $paymentType = null;
public ?string $paymentLabel = null;
public ?BankAccount $bankAccount = null;
/**
* @var array<int, string>
*/
public array $participantsStatus = [];
/**
* @var array<int, PersonalData>
*/
public array $participants = [];
/**
* @var array<int, Service>
*/
public array $transportationServices = [];
/**
* @var array<int, Service>
*/
public array $additionalServices = [];
/**
* @var array<int, Room>
*/
public array $rooms = [];
/**
* @var array<int, Pickup>
*/
public array $pickups = [];
/**
* @var array<int, Pickup>
*/
public array $dropOffs = [];
/**
* @var array<int, Surcharge>
*/
public array $surcharges = [];
/**
* @var array<string|int, Insurance>
*/
public array $insurances = [];
public ?int $invoiceNumber = null;
public ?float $totalPrice = null;
+15
View File
@@ -12,8 +12,23 @@ namespace App\BusProNet\Model;
*/
class CrmAttributes
{
/**
* @var array<int, CrmSelectionGroup>|null
*/
public ?array $selectionGroups = null;
/**
* @var array<int, CrmAction>|null
*/
public ?array $crmActions = null;
/**
* @var array<int, string>
*/
public array $hotelCodes = [];
/**
* @var array<int, string>
*/
public array $roles = [];
}
@@ -38,6 +38,9 @@ class CrmSelectionGroup
#[Groups(['api:single'])]
public ?string $label = null;
/**
* @var array<int, CrmSelection>|null
*/
#[Groups(['api:single'])]
public ?array $selections = null;
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Extended availability details for one service from VERFUEGBARKEIT2.
*
* This model augments classic availability with optional timing/date/description
* fields used to enrich persisted travel snapshots.
*/
class ExtendedAvailability
{
public ?int $serviceId = null;
public ?string $status = null;
public ?int $available = null;
public ?float $price = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?string $timeFrom = null;
public ?string $description = null;
public ?int $ageFrom = null;
public ?int $ageTo = null;
public ?bool $mandatory = null;
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Response model for extended availability endpoint payloads.
*/
class ExtendedServiceAvailabilityResponse
{
/**
* @param array<int, ExtendedAvailability> $services
* @param array<string> $allowedBookingStatus
*/
public function __construct(
private readonly array $services,
public readonly array $allowedBookingStatus = [],
public readonly ?string $travelStatus = null,
) {
}
/**
* @return array<int, ExtendedAvailability>
*/
public function getServices(): array
{
return $this->services;
}
}
+22 -1
View File
@@ -29,6 +29,9 @@ class Travel
#[Groups(['api:list', 'api:single'])]
public ?string $productCode = null;
#[Groups(['api:list', 'api:single'])]
public ?int $productId = null;
#[Groups(['api:list', 'api:single'])]
public ?string $label = null;
@@ -68,30 +71,48 @@ class Travel
#[Groups(['api:list', 'api:single'])]
public ?float $priceFrom = null;
/**
* @var array<int, CrmSelectionGroup>
*/
#[Groups(['api:single'])]
public array $selectionGroups = [];
/**
* @var array<int, Service>
*/
#[Groups(['api:single'])]
public array $additionalServices = [];
#[Groups(['api:single'])]
public bool $additionalServicesMutable = true;
/**
* @var array<int, Service>
*/
#[Groups(['api:single'])]
public array $transportationServices = [];
#[Groups(['api:single'])]
public bool $transportationServicesMutable = true;
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single'])]
public array $pickups = [];
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single'])]
public array $dropOffs = [];
#[Groups(['api:single'])]
public bool $pickupsMutable = true;
/**
* @var array<int, Room>
*/
#[Groups(['api:single'])]
public array $rooms = [];
@@ -108,7 +129,7 @@ class Travel
public ?Guide $guide = null;
/**
* @var array<Insurance> Available insurances for this travel package
* @var array<string|int, Insurance> Available insurances for this travel package
*/
#[Groups(['api:single'])]
public array $insurances = [];
@@ -61,6 +61,8 @@ class ApiResponseParser extends AbstractParser
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\ExtendedAvailability;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parser for VERFUEGBARKEIT2 service payloads.
*
* Extracts extended service-level attributes (dates, times, hints, constraints)
* plus optional travel-level booking-status metadata.
*/
class ExtendedAvailabilitiesParser extends AbstractParser
{
/**
* Parses extended availability services from API result node.
*/
public function parseServices(Crawler $result): ExtendedServiceAvailabilityResponse
{
$availabilities = [];
$result
->filterXPath('//leistungen/leistung')
->each(function (Crawler $node) use (&$availabilities) {
$serviceId = (int) $node->attr('id');
$availability = new ExtendedAvailability();
$availability->serviceId = $serviceId;
$availability->status = $node->attr('status');
$availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis'));
$availability->dateFrom = $this->stringToDate($node->attr('termin'));
$availability->dateTo = $this->stringToDate($node->attr('bis'));
$availability->timeFrom = $node->attr('uhrzeit_von');
$availability->description = $node->attr('hinweis');
$ageFrom = $node->attr('altervon');
if (null !== $ageFrom && '' !== trim($ageFrom)) {
$availability->ageFrom = (int) $ageFrom;
}
$ageTo = $node->attr('alterbis');
if (null !== $ageTo && '' !== trim($ageTo)) {
$availability->ageTo = (int) $ageTo;
}
$mandatory = $node->attr('pflicht');
if (null !== $mandatory && '' !== trim($mandatory)) {
$availability->mandatory = $this->stringToBool($mandatory);
}
$availabilities[$serviceId] = $availability;
})
;
$allowedBookingStatus = [];
$travelStatus = null;
$reiseNode = $result->filterXPath('//reise');
if (0 < $reiseNode->count()) {
$travelStatus = $reiseNode->attr('status');
$bookingStatusPossible = $reiseNode->attr('buchungstatusmoeglich') ?? '';
if ('' !== trim($bookingStatusPossible)) {
$allowedBookingStatus = str_split($bookingStatusPossible);
}
}
return new ExtendedServiceAvailabilityResponse($availabilities, $allowedBookingStatus, $travelStatus);
}
}
+3
View File
@@ -67,6 +67,9 @@ class TravelParser extends AbstractParser
$travel->dateFrom = $dateFrom;
$travel->dateTo = $dateTo;
$travel->code = $this->getAttrOrNullValue($node, 'code');
$travel->productId = null !== $this->getAttrOrNullValue($node, 'idprodukt')
? (int) $this->getAttrOrNullValue($node, 'idprodukt')
: null;
// Parse product code from parent reise node using DOM
$domNode = $node->getNode(0);
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\TravelSnapshotService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn:refresh-travel-snapshot',
description: 'Refreshes active travel snapshots using extended availability data.',
)]
/**
* Console entrypoint for refreshing persisted travel snapshots.
*
* Runs extended availability enrichment in batch mode and triggers
* retention cleanup in the same invocation.
*/
class BpnRefreshTravelSnapshotCommand extends Command
{
public function __construct(
private readonly TravelSnapshotService $snapshotService,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
/**
* Configures refresh-control and cleanup options.
*/
protected function configure(): void
{
$this
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of snapshots to process', '500')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Refresh even if snapshot was refreshed recently')
->addOption('refresh-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since last refresh before a snapshot is eligible (ignored with --force)', '360')
;
}
/**
* Executes snapshot refresh and purge flow.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$limit = (int) $input->getOption('limit');
$force = true === $input->getOption('force');
$refreshAfterMinutes = (int) $input->getOption('refresh-after');
$result = $this->snapshotService->refreshExtendedSnapshots($limit, $force, $refreshAfterMinutes);
$io->success(sprintf(
'Snapshot refresh complete: %d processed, %d updated, %d failed',
$result['processed'],
$result['updated'],
$result['failed']
));
$this->logger->info('Travel snapshot refresh finished', $result);
$deleted = $this->snapshotService->purgeExpiredSnapshots();
$io->note(sprintf('Deleted %d expired snapshots.', $deleted));
return Command::SUCCESS;
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ class TravelController extends AbstractController
bool $preferRemote = false,
): ?Travel {
return match ($source) {
'local' => $this->travelDataService->getTravelDataFromXml($dateId, $hotelId),
'local' => $this->travelDataService->getTravelDataFromLocal($dateId, $hotelId),
'remote' => $this->travelDataService->getTravelDataFromApi($dateId, $hotelId),
default => $this->travelDataService->getTravelData($dateId, $hotelId, $preferRemote),
};
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\TravelSnapshotRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TravelSnapshotRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_snapshot_date_hotel', columns: ['date_id', 'hotel_id'])]
#[ORM\Index(name: 'idx_snapshot_date_code', columns: ['date_code'])]
#[ORM\Index(name: 'idx_snapshot_product_id', columns: ['product_id'])]
#[ORM\Index(name: 'idx_snapshot_date_to', columns: ['date_to'])]
#[ORM\Index(name: 'idx_snapshot_extended_refreshed_at', columns: ['extended_refreshed_at'])]
class TravelSnapshot
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
private int $dateId;
#[ORM\Column(type: 'integer')]
private int $hotelId;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $dateCode = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $label = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $productId = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $productCode = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $hotelCode = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $hotelLabel = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column(type: 'text')]
private string $payload;
#[ORM\Column(type: 'string', length: 64)]
private string $payloadHash;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $capturedAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $extendedRefreshedAt = null;
public function __construct(int $dateId, int $hotelId, string $payload, string $payloadHash)
{
$now = new \DateTimeImmutable();
$this->dateId = $dateId;
$this->hotelId = $hotelId;
$this->payload = $payload;
$this->payloadHash = $payloadHash;
$this->capturedAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getDateId(): int
{
return $this->dateId;
}
public function getHotelId(): int
{
return $this->hotelId;
}
public function getDateCode(): ?string
{
return $this->dateCode;
}
public function setDateCode(?string $dateCode): static
{
$this->dateCode = $dateCode;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getProductId(): ?int
{
return $this->productId;
}
public function setProductId(?int $productId): static
{
$this->productId = $productId;
return $this;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$this->productCode = $productCode;
return $this;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function setHotelCode(?string $hotelCode): static
{
$this->hotelCode = $hotelCode;
return $this;
}
public function getHotelLabel(): ?string
{
return $this->hotelLabel;
}
public function setHotelLabel(?string $hotelLabel): static
{
$this->hotelLabel = $hotelLabel;
return $this;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
public function getPayload(): string
{
return $this->payload;
}
public function setPayload(string $payload): static
{
$this->payload = $payload;
return $this;
}
public function getPayloadHash(): string
{
return $this->payloadHash;
}
public function setPayloadHash(string $payloadHash): static
{
$this->payloadHash = $payloadHash;
return $this;
}
public function getCapturedAt(): \DateTimeImmutable
{
return $this->capturedAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function touchUpdatedAt(): static
{
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getExtendedRefreshedAt(): ?\DateTimeImmutable
{
return $this->extendedRefreshedAt;
}
public function setExtendedRefreshedAt(?\DateTimeImmutable $extendedRefreshedAt): static
{
$this->extendedRefreshedAt = $extendedRefreshedAt;
return $this;
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TravelSnapshot;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Repository for querying and maintaining persisted travel snapshots.
*
* Provides focused lookup methods used by DB-primary travel loading,
* mapping generation, extended refresh scheduling, and retention cleanup.
*
* @extends ServiceEntityRepository<TravelSnapshot>
*/
class TravelSnapshotRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TravelSnapshot::class);
}
/**
* Returns the exact snapshot for a travel date and hotel combination.
*/
public function findByDateAndHotel(int $dateId, int $hotelId): ?TravelSnapshot
{
return $this->findOneBy([
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
}
/**
* Returns a fallback snapshot for the given date when hotel-specific lookup misses.
*/
public function findFirstByDateId(int $dateId): ?TravelSnapshot
{
return $this->createQueryBuilder('s')
->where('s.dateId = :dateId')
->setParameter('dateId', $dateId)
->orderBy('s.id', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* Returns the first known product ID for a travel date, if available.
*/
public function findProductIdByDateId(int $dateId): ?int
{
$value = $this->createQueryBuilder('s')
->select('s.productId')
->where('s.dateId = :dateId')
->andWhere('s.productId IS NOT NULL')
->setParameter('dateId', $dateId)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if (null === $value) {
return null;
}
return (int) $value['productId'];
}
/**
* Returns all snapshots sorted deterministically for mapping aggregation.
*
* @return array<int, TravelSnapshot>
*/
public function findAllForMapping(?int $limit = null): array
{
$qb = $this->createQueryBuilder('s')
->orderBy('s.dateId', 'ASC')
->addOrderBy('s.hotelId', 'ASC');
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $qb->getQuery()->getResult();
}
/**
* Returns snapshots eligible for extended availability refresh.
*
* @return array<int, TravelSnapshot>
*/
public function findRefreshCandidates(
\DateTimeImmutable $dateToThreshold,
\DateTimeImmutable $refreshBefore,
int $limit,
): array {
return $this->createQueryBuilder('s')
->where('s.dateTo IS NULL OR s.dateTo >= :dateToThreshold')
->andWhere('s.extendedRefreshedAt IS NULL OR s.extendedRefreshedAt < :refreshBefore')
->setParameter('dateToThreshold', $dateToThreshold)
->setParameter('refreshBefore', $refreshBefore)
->orderBy('s.extendedRefreshedAt', 'ASC')
->addOrderBy('s.id', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Deletes snapshots whose travel end date is older than the given threshold.
*/
public function deleteExpiredSnapshots(\DateTimeImmutable $beforeDate): int
{
return (int) $this->createQueryBuilder('s')
->delete()
->where('s.dateTo IS NOT NULL')
->andWhere('s.dateTo < :beforeDate')
->setParameter('beforeDate', $beforeDate)
->getQuery()
->execute();
}
}
+4 -4
View File
@@ -112,7 +112,7 @@ class BookingService
* Retrieves the booking DTO from the session and restores the Travel object.
*
* After deserialization the DTO contains only a Travel skeleton with the ID.
* This method replaces it with the full Travel from cache via hydrate().
* This method replaces it with the full Travel via hydrate().
*
* @param Request $request The HTTP request containing session data
* @param string $mode The booking mode (create/edit)
@@ -159,11 +159,11 @@ class BookingService
}
/**
* Restores the full Travel object from cache after session deserialization.
* Restores the full Travel object after session deserialization.
*
* BookingDto::__serialize() replaces Travel with just its ID to keep session
* payloads small. This method fetches the complete Travel from the
* TravelDataService cache and sets it on both the DTO and the Booking reference.
* payloads small. This method fetches the complete Travel (from DB snapshot or
* XML fallback) and sets it on both the DTO and the Booking reference.
*/
private function hydrate(BookingDto $bookingDto): void
{
+126 -60
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
@@ -17,17 +18,19 @@ use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use Flagception\Manager\FeatureManagerInterface;
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.
* 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 XML files and remote API calls. It handles caching,
* error recovery, and data enrichment for both data sources.
* 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.
*/
class TravelDataService
{
@@ -44,6 +47,8 @@ class TravelDataService
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly TravelSnapshotService $travelSnapshotService,
private readonly FeatureManagerInterface $featureManager,
private readonly bool $preferRemote = false,
private readonly bool $enableFallback = true,
) {
@@ -53,13 +58,11 @@ class TravelDataService
* 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.
* 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
* @param bool $enableCache Whether to use caching for this request
*
* @return Travel|null The travel data or null if not found in any source
*/
@@ -67,30 +70,8 @@ class TravelDataService
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);
}
return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote ?? $this->preferRemote);
}
/**
@@ -110,13 +91,6 @@ class TravelDataService
$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,
@@ -140,6 +114,75 @@ class TravelDataService
return null;
}
// Persist snapshot separately so a DB/serializer failure does not discard a
// successfully loaded travel.
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.
*/
public function getTravelDataFromLocal(int $dateId, ?int $hotelId = null): ?Travel
{
$travel = $this->getTravelDataFromSnapshot($dateId, $hotelId)
?? $this->getTravelDataFromXml($dateId, $hotelId);
if (null !== $travel) {
$this->hydrateInsurancePackageRelationships($travel->insurances);
}
return $travel;
}
private function getTravelDataFromSnapshot(int $dateId, ?int $hotelId): ?Travel
{
if (!$this->featureManager->isActive('travel_snapshot')) {
return null;
}
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;
}
/**
@@ -200,18 +243,22 @@ class TravelDataService
}
/**
* Check if travel data exists in XML files.
* Check if travel data exists in local persisted sources.
*
* Performs a lightweight check to determine if travel data exists in XML
* files without loading the full travel object.
* Performs a lightweight check to determine if travel data can be served from
* local sources 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
* @return bool True if travel data exists in local sources
*/
public function existsInXml(int $dateId, ?int $hotelId = null): bool
public function existsLocally(int $dateId, ?int $hotelId = null): bool
{
if (true === $this->travelSnapshotService->exists($dateId, $hotelId)) {
return true;
}
$mapping = $this->generateFilesMap();
if (false === isset($mapping[$dateId])) {
@@ -229,7 +276,7 @@ class TravelDataService
/**
* Get information about available data sources for a travel.
*
* Returns information about which data sources (local XML, remote API, or both) have
* Returns information about which data sources (local persisted, remote API, or both) have
* data available for the specified travel.
*
* @param int $dateId The travel date ID to check
@@ -240,7 +287,7 @@ class TravelDataService
public function getAvailableSources(int $dateId, ?int $hotelId = null): array
{
return [
static::SOURCE_LOCAL => $this->existsInXml($dateId, $hotelId),
static::SOURCE_LOCAL => $this->existsLocally($dateId, $hotelId),
static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId),
];
}
@@ -253,7 +300,7 @@ class TravelDataService
*
* @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
* @param bool $preferRemote Whether to prefer remote API over local sources
*
* @return Travel|null The travel data or null if not found
*/
@@ -298,7 +345,7 @@ class TravelDataService
private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel
{
return match ($source) {
self::SOURCE_LOCAL => $this->getTravelDataFromXml($dateId, $hotelId),
self::SOURCE_LOCAL => $this->getTravelDataFromLocal($dateId, $hotelId),
self::SOURCE_REMOTE => $this->getTravelDataFromApi($dateId, $hotelId),
default => null,
};
@@ -317,14 +364,20 @@ class TravelDataService
public function mapDateCodeToId(string $dateCode): ?int
{
try {
$dateId = $this->travelLoader->mapCodeToId($dateCode);
$mapping = $this->generateFilesMap();
$dateCodes = array_column($mapping, 'code', 'id');
$dateId = array_search($dateCode, $dateCodes, true);
if (false === $dateId) {
return null;
}
$this->logger->debug('Date code mapping', [
'dateCode' => $dateCode,
'dateId' => $dateId,
]);
return $dateId;
return (int) $dateId;
} catch (\Exception $e) {
$this->logger->error('Failed to map date code to ID', [
'dateCode' => $dateCode,
@@ -380,7 +433,8 @@ class TravelDataService
public function mapDateIdToProductId(int $dateId): ?int
{
try {
$productId = $this->travelLoader->mapDateIdToProductId($dateId);
$productId = $this->travelSnapshotService->findProductIdByDateId($dateId);
$productId = $productId ?? $this->travelLoader->mapDateIdToProductId($dateId);
$this->logger->debug('Date ID to product ID mapping', [
'dateId' => $dateId,
@@ -409,21 +463,36 @@ class TravelDataService
*/
public function generateFilesMap(): array
{
$mapping = [];
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', [
$this->logger->warning('Failed to generate XML files mapping, fallback to snapshots only', [
'error' => $e->getMessage(),
]);
return [];
}
$snapshotMapping = $this->travelSnapshotService->generateMapping();
foreach ($snapshotMapping as $dateId => $snapshotEntry) {
if (false === isset($mapping[$dateId])) {
$mapping[$dateId] = $snapshotEntry;
continue;
}
foreach ($snapshotEntry['hotels'] as $hotelId => $hotelData) {
if (false === isset($mapping[$dateId]['hotels'][$hotelId])) {
$mapping[$dateId]['hotels'][$hotelId] = $hotelData;
}
}
}
$this->logger->debug('Generated files mapping', [
'count' => count($mapping),
]);
return $mapping;
}
/**
@@ -671,9 +740,6 @@ class TravelDataService
// 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,
+376
View File
@@ -0,0 +1,376 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\ExtendedAvailability;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DayTimeUtility;
use App\Entity\TravelSnapshot;
use App\Repository\TravelSnapshotRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Application service for DB-backed travel snapshots.
*
* Responsibilities:
* - Store and load Travel graphs as JSON payloads.
* - Expose snapshot-derived mapping/product lookup helpers.
* - Enrich snapshots with extended availability data.
* - Purge expired snapshot records.
*/
class TravelSnapshotService
{
public function __construct(
private readonly TravelSnapshotRepository $snapshotRepository,
private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient,
private readonly LoggerInterface $logger,
private readonly SerializerInterface $serializer,
private readonly int $retentionBufferDays = 14,
) {
}
/**
* Checks whether a snapshot exists for the given date/hotel combination.
*/
public function exists(int $dateId, ?int $hotelId = null): bool
{
return null !== $this->findSnapshot($dateId, $hotelId);
}
/**
* Loads a Travel aggregate from snapshot payload.
*
* Returns null when no snapshot exists or payload deserialization fails.
*/
public function loadTravel(int $dateId, ?int $hotelId = null): ?Travel
{
$snapshot = $this->findSnapshot($dateId, $hotelId);
if (null === $snapshot) {
return null;
}
try {
$travel = $this->serializer->deserialize(
$snapshot->getPayload(),
Travel::class,
'json'
);
} catch (\Throwable $e) {
$this->logger->warning('Snapshot payload cannot be deserialized to Travel', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'snapshotId' => $snapshot->getId(),
'error' => $e->getMessage(),
]);
return null;
}
if (false === $travel instanceof Travel) {
$this->logger->warning('Snapshot payload deserialization returned unexpected type', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'snapshotId' => $snapshot->getId(),
'type' => get_debug_type($travel),
]);
return null;
}
return $travel;
}
/**
* Creates or updates a snapshot from a Travel aggregate.
*
* Uses payload hash comparison to skip unnecessary writes.
*/
public function upsertFromTravel(Travel $travel): void
{
if (null === $travel->id || null === $travel->hotelId) {
return;
}
$payload = $this->serializer->serialize($travel, 'json');
$payloadHash = hash('sha256', $payload);
$snapshot = $this->snapshotRepository->findByDateAndHotel($travel->id, $travel->hotelId);
if (null === $snapshot) {
$snapshot = new TravelSnapshot($travel->id, $travel->hotelId, $payload, $payloadHash);
$this->applyTravelMetadata($snapshot, $travel);
$this->entityManager->persist($snapshot);
$this->entityManager->flush();
return;
}
if ($payloadHash === $snapshot->getPayloadHash()) {
return;
}
$snapshot
->setPayload($payload)
->setPayloadHash($payloadHash)
->touchUpdatedAt();
$this->applyTravelMetadata($snapshot, $travel);
$this->entityManager->flush();
}
/**
* Builds travel mapping entries from snapshots.
*
* @return array<int, array<string, mixed>>
*/
public function generateMapping(): array
{
$mapping = [];
foreach ($this->snapshotRepository->findAllForMapping() as $snapshot) {
$dateId = $snapshot->getDateId();
$hotelId = $snapshot->getHotelId();
if (false === isset($mapping[$dateId])) {
$mapping[$dateId] = [
'id' => $dateId,
'code' => $snapshot->getDateCode(),
'label' => $snapshot->getLabel(),
'dateFrom' => $snapshot->getDateFrom(),
'dateTo' => $snapshot->getDateTo(),
'hotels' => [],
'file' => null,
];
}
$mapping[$dateId]['hotels'][$hotelId] = [
'id' => $hotelId,
'code' => $snapshot->getHotelCode(),
'label' => $snapshot->getHotelLabel(),
];
}
return $mapping;
}
/**
* Finds product ID for a travel date based on snapshot metadata.
*/
public function findProductIdByDateId(int $dateId): ?int
{
return $this->snapshotRepository->findProductIdByDateId($dateId);
}
/**
* Refreshes snapshot payloads with extended availability data.
*
* @return array{processed:int,updated:int,failed:int}
*/
public function refreshExtendedSnapshots(int $limit = 500, bool $force = false, int $refreshAfterMinutes = 360): array
{
$dateToThreshold = new \DateTimeImmutable(sprintf('-%d days', $this->retentionBufferDays));
$refreshBefore = new \DateTimeImmutable(sprintf('-%d minutes', $refreshAfterMinutes));
$candidates = true === $force
? $this->snapshotRepository->findAllForMapping($limit)
: $this->snapshotRepository->findRefreshCandidates($dateToThreshold, $refreshBefore, $limit);
$updated = 0;
$failed = 0;
/** @var array<int, ExtendedServiceAvailabilityResponse|null> $extendedResponseByDateId */
$extendedResponseByDateId = [];
foreach ($candidates as $snapshot) {
$dateId = $snapshot->getDateId();
$travel = $this->deserializeTravelFromSnapshot($snapshot);
if (null === $travel) {
++$failed;
continue;
}
if (false === array_key_exists($dateId, $extendedResponseByDateId)) {
$response = null;
try {
$response = $this->apiClient->getAvailabilitiesExtended($dateId);
} catch (ApiClientException $e) {
$extendedResponseByDateId[$dateId] = null;
$this->logger->warning('Failed to fetch extended availability', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
}
if (null !== $response && false === $response instanceof Notification) {
$extendedResponseByDateId[$dateId] = $response;
} elseif (false === isset($extendedResponseByDateId[$dateId])) {
$extendedResponseByDateId[$dateId] = null;
}
}
$response = $extendedResponseByDateId[$dateId];
if (null === $response) {
++$failed;
continue;
}
$this->applyExtendedAvailability($travel, $response);
$payload = $this->serializer->serialize($travel, 'json');
$payloadHash = hash('sha256', $payload);
if ($payloadHash !== $snapshot->getPayloadHash()) {
$snapshot->setPayload($payload)->setPayloadHash($payloadHash);
$this->applyTravelMetadata($snapshot, $travel);
}
$snapshot->setExtendedRefreshedAt(new \DateTimeImmutable())->touchUpdatedAt();
$this->entityManager->flush();
++$updated;
}
return [
'processed' => count($candidates),
'updated' => $updated,
'failed' => $failed,
];
}
/**
* Purges snapshots past retention threshold.
*/
public function purgeExpiredSnapshots(): int
{
$beforeDate = new \DateTimeImmutable(sprintf('-%d days', $this->retentionBufferDays));
return $this->snapshotRepository->deleteExpiredSnapshots($beforeDate);
}
/**
* Resolves snapshot by exact date/hotel or by date fallback.
*/
private function findSnapshot(int $dateId, ?int $hotelId = null): ?TravelSnapshot
{
if (null !== $hotelId) {
return $this->snapshotRepository->findByDateAndHotel($dateId, $hotelId);
}
return $this->snapshotRepository->findFirstByDateId($dateId);
}
/**
* Deserializes Travel from an already-loaded snapshot without an extra DB query.
*/
private function deserializeTravelFromSnapshot(TravelSnapshot $snapshot): ?Travel
{
try {
$travel = $this->serializer->deserialize($snapshot->getPayload(), Travel::class, 'json');
} catch (\Throwable $e) {
$this->logger->warning('Snapshot payload cannot be deserialized to Travel', [
'snapshotId' => $snapshot->getId(),
'error' => $e->getMessage(),
]);
return null;
}
if (false === $travel instanceof Travel) {
return null;
}
return $travel;
}
/**
* Synchronizes lightweight lookup metadata from Travel into snapshot row.
*/
private function applyTravelMetadata(TravelSnapshot $snapshot, Travel $travel): void
{
$snapshot
->setDateCode($travel->code)
->setLabel($travel->label)
->setProductCode($travel->productCode)
->setProductId($travel->productId)
->setDateFrom($travel->dateFrom)
->setDateTo($travel->dateTo)
->setHotelCode($travel->hotel?->code)
->setHotelLabel($travel->hotel?->name);
}
/**
* Applies extended availability deltas to matching Travel services.
*/
private function applyExtendedAvailability(Travel $travel, ExtendedServiceAvailabilityResponse $response): void
{
foreach ($response->getServices() as $serviceId => $availability) {
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
if (false === $service instanceof Service) {
continue;
}
$this->applyExtendedToService($service, $availability);
}
if ([] !== $response->allowedBookingStatus) {
$travel->allowedBookingStatus = $response->allowedBookingStatus;
}
if (null !== $response->travelStatus && '' !== trim($response->travelStatus)) {
$travel->status = $response->travelStatus;
}
}
/**
* Applies one extended availability record to one Service model.
*/
private function applyExtendedToService(Service $service, ExtendedAvailability $availability): void
{
if (null !== $availability->available) {
$service->available = $availability->available;
}
if (null !== $availability->status && '' !== trim($availability->status)) {
$service->status = $availability->status;
}
if (null !== $availability->price) {
$service->price = $availability->price;
}
if (null !== $availability->dateFrom) {
$service->dateFrom = $availability->dateFrom;
}
if (null !== $availability->dateTo) {
$service->dateTo = $availability->dateTo;
}
if (null !== $availability->description && '' !== trim($availability->description)) {
$service->description = $availability->description;
}
if (null !== $availability->ageFrom) {
$service->ageFrom = $availability->ageFrom;
}
if (null !== $availability->ageTo) {
$service->ageTo = $availability->ageTo;
}
if (null !== $availability->mandatory) {
$service->mandatory = $availability->mandatory;
}
if (null !== $availability->timeFrom && '' !== trim($availability->timeFrom)) {
$service->timeFrom = $availability->timeFrom;
$service->dayTime = (new DayTimeUtility())->mapTime($availability->timeFrom);
}
}
}