diff --git a/src/Entity/TravelSnapshot.php b/src/Entity/TravelSnapshot.php index 57b7071..683a40b 100644 --- a/src/Entity/TravelSnapshot.php +++ b/src/Entity/TravelSnapshot.php @@ -13,6 +13,18 @@ use Doctrine\ORM\Mapping as ORM; #[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'])] +/** + * Persisted copy of a Travel aggregate for travels whose source XML may no longer be available. + * + * Each row stores the full serialized Travel graph as a JSON payload alongside a set of + * denormalized metadata columns (code, label, dates, hotel) that allow filtering and + * mapping lookups without deserializing the payload. + * + * Three timestamps track the lifecycle of the snapshot: + * - capturedAt: when the snapshot was first written + * - updatedAt: when the payload or metadata was last modified + * - extendedRefreshedAt: when extended availability (VERFUEGBARKEIT2) was last applied + */ class TravelSnapshot { #[ORM\Id] @@ -50,9 +62,11 @@ class TravelSnapshot #[ORM\Column(type: 'date_immutable', nullable: true)] private ?\DateTimeImmutable $dateTo = null; + /** Full JSON-serialized Travel aggregate. */ #[ORM\Column(type: 'text')] private string $payload; + /** SHA-256 of payload; compared before every write to skip no-op flushes. */ #[ORM\Column(type: 'string', length: 64)] private string $payloadHash; diff --git a/src/Repository/TravelSnapshotRepository.php b/src/Repository/TravelSnapshotRepository.php index 9ffe846..86cbfa5 100644 --- a/src/Repository/TravelSnapshotRepository.php +++ b/src/Repository/TravelSnapshotRepository.php @@ -98,7 +98,9 @@ class TravelSnapshotRepository extends ServiceEntityRepository int $limit, ): array { return $this->createQueryBuilder('s') + // dateTo IS NULL: snapshots without a known end date are always considered active. ->where('s.dateTo IS NULL OR s.dateTo >= :dateToThreshold') + // extendedRefreshedAt IS NULL: never-refreshed snapshots rank as oldest. ->andWhere('s.extendedRefreshedAt IS NULL OR s.extendedRefreshedAt < :refreshBefore') ->setParameter('dateToThreshold', $dateToThreshold) ->setParameter('refreshBefore', $refreshBefore) @@ -116,6 +118,8 @@ class TravelSnapshotRepository extends ServiceEntityRepository { return (int) $this->createQueryBuilder('s') ->delete() + // Snapshots with no known end date are excluded from purging as a safety net; + // they must be cleaned up manually if they become stale. ->where('s.dateTo IS NOT NULL') ->andWhere('s.dateTo < :beforeDate') ->setParameter('beforeDate', $beforeDate) diff --git a/src/Service/TravelSnapshotService.php b/src/Service/TravelSnapshotService.php index f8e3b05..7a81b34 100644 --- a/src/Service/TravelSnapshotService.php +++ b/src/Service/TravelSnapshotService.php @@ -97,6 +97,7 @@ class TravelSnapshotService */ 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; } @@ -191,6 +192,10 @@ class TravelSnapshotService $extendedResponseByDateId = []; foreach ($candidates as $snapshot) { + // Skip travels whose XML is still live; their snapshot is kept authoritative + // by the XML load path (getTravelDataFromXml → upsertFromTravel). An empty + // $xmlAvailableDateIds means the file map could not be loaded, so fall back + // to refreshing everything rather than skipping all candidates. if ([] !== $xmlAvailableDateIds && in_array($snapshot->getDateId(), $xmlAvailableDateIds, true)) { continue; } @@ -203,6 +208,8 @@ class TravelSnapshotService 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 { @@ -215,6 +222,8 @@ class TravelSnapshotService ]); } + // 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])) { @@ -236,6 +245,8 @@ class TravelSnapshotService $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();