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> */ 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|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 $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 $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); } } }