Files
myep/src/Service/TravelSnapshotManager.php
T
2026-04-16 13:34:54 +02:00

400 lines
14 KiB
PHP

<?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 TravelSnapshotManager
{
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;
}
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
{
// Both fields form the composite unique key; nothing to persist without them.
if (null === $travel->id || null === $travel->hotelId) {
return;
}
$payload = $this->serializer->serialize($travel, 'json', ['groups' => ['snapshot']]);
$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.
*
* @param array<int>|null $xmlAvailableDateIds
*
* @return array{processed:int,updated:int,failed:int}
*/
public function refreshExtendedSnapshots(
int $limit = 500,
bool $force = false,
int $refreshAfterMinutes = 360,
?array $xmlAvailableDateIds = null,
): array {
$dateToThreshold = new \DateTimeImmutable('today');
$refreshBefore = new \DateTimeImmutable(sprintf('-%d minutes', $refreshAfterMinutes));
$candidates = true === $force
? $this->snapshotRepository->findAllForMapping($limit)
: $this->snapshotRepository->findRefreshCandidates($dateToThreshold, $refreshBefore, $limit);
$processed = 0;
$updated = 0;
$failed = 0;
/** @var array<int, ExtendedServiceAvailabilityResponse|null> $extendedResponseByDateId */
$extendedResponseByDateId = [];
foreach ($candidates as $snapshot) {
// null → file map unavailable; skip all candidates (conservative fallback)
// [] → no XML files exist; fall through and refresh everything
// [...] → skip travels whose XML is still live
if (null === $xmlAvailableDateIds) {
continue;
}
if ([] !== $xmlAvailableDateIds && in_array($snapshot->getDateId(), $xmlAvailableDateIds, true)) {
continue;
}
++$processed;
$dateId = $snapshot->getDateId();
$travel = $this->deserializeTravelFromSnapshot($snapshot);
if (null === $travel) {
++$failed;
continue;
}
// Cache the API response by dateId: multiple hotel snapshots share one travel
// date, so a single API call covers all of them within the same batch.
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(),
]);
}
// A Notification response signals a business-level "no data" answer
// from the API rather than an error; treat it the same as a null response.
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', ['groups' => ['snapshot']]);
$payloadHash = hash('sha256', $payload);
if ($payloadHash !== $snapshot->getPayloadHash()) {
$snapshot->setPayload($payload)->setPayloadHash($payloadHash);
$this->applyTravelMetadata($snapshot, $travel);
}
// Always stamp the refresh timestamp even when the payload was unchanged,
// so the candidate query does not re-select this snapshot on the next run.
$snapshot->setExtendedRefreshedAt(new \DateTimeImmutable())->touchUpdatedAt();
$this->entityManager->flush();
++$updated;
}
return [
'processed' => $processed,
'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);
}
/**
* Purges future snapshots whose dateId is no longer present in the XML export.
*
* @param array<int> $activeXmlDateIds dateIds currently present in the XML file map
*/
public function purgeOrphanedFutureSnapshots(array $activeXmlDateIds): int
{
return $this->snapshotRepository->deleteOrphanedFutureSnapshots(
$activeXmlDateIds,
new \DateTimeImmutable('today'),
);
}
/**
* 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;
}
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);
}
}
}