diff --git a/src/Command/BpnXmlSyncCommand.php b/src/Command/BpnXmlSyncCommand.php index f2290b8..52ce554 100644 --- a/src/Command/BpnXmlSyncCommand.php +++ b/src/Command/BpnXmlSyncCommand.php @@ -7,6 +7,7 @@ namespace App\Command; use App\BusProNet\Model\XmlExportInfo; use App\BusProNet\XmlLoader\TravelLoader; use App\Service\TravelDataService; +use App\Service\TravelSnapshotService; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use League\Flysystem\StorageAttributes; @@ -41,6 +42,7 @@ class BpnXmlSyncCommand extends Command private readonly LoggerInterface $logger, private readonly TravelLoader $travelLoader, private readonly TravelDataService $travelDataService, + private readonly TravelSnapshotService $snapshotService, ) { parent::__construct(); } @@ -124,6 +126,7 @@ class BpnXmlSyncCommand extends Command $xmlFileMap = null; $snapshotResult = null; + $orphanedDeleted = null; try { $xmlFileMap = $this->travelLoader->generateFilesMap(); } catch (\Throwable $e) { @@ -142,18 +145,24 @@ class BpnXmlSyncCommand extends Command ); $io->progressFinish(); + + $orphanedDeleted = $this->snapshotService->purgeOrphanedFutureSnapshots(array_keys($xmlFileMap)); } $successMessage = sprintf('Sync complete: %d files updated, %d files deleted', $syncResult['updated'], $syncResult['deleted']); if (null !== $snapshotResult) { $successMessage .= sprintf(', snapshots: %d processed, %d failed', $snapshotResult['processed'], $snapshotResult['failed']); } + if (null !== $orphanedDeleted) { + $successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted); + } $io->success($successMessage); $this->logger->info('XML sync completed', [ 'files_updated' => $syncResult['updated'], 'files_deleted' => $syncResult['deleted'], 'snapshots_processed' => $snapshotResult['processed'] ?? null, 'snapshots_failed' => $snapshotResult['failed'] ?? null, + 'orphaned_deleted' => $orphanedDeleted, 'remote_timestamp' => $remoteInfo->lastTransfer->format('c'), ]); diff --git a/src/Repository/TravelSnapshotRepository.php b/src/Repository/TravelSnapshotRepository.php index 86cbfa5..672bf3d 100644 --- a/src/Repository/TravelSnapshotRepository.php +++ b/src/Repository/TravelSnapshotRepository.php @@ -126,4 +126,31 @@ class TravelSnapshotRepository extends ServiceEntityRepository ->getQuery() ->execute(); } + + /** + * Deletes future snapshots whose dateId is no longer present in the XML export. + * + * Only snapshots with dateFrom > today are removed; in-progress and past travels + * are intentionally left intact so the extended-availability fallback can still serve them. + * + * @param array $activeXmlDateIds dateIds currently present in the XML file map + */ + public function deleteOrphanedFutureSnapshots(array $activeXmlDateIds, \DateTimeImmutable $today): int + { + // An empty list means the XML export is unavailable or empty — refuse to purge + // anything rather than deleting all future snapshots due to NOT IN ([]) = 1=1. + if ([] === $activeXmlDateIds) { + return 0; + } + + return (int) $this->createQueryBuilder('s') + ->delete() + ->where('s.dateFrom IS NOT NULL') + ->andWhere('s.dateFrom > :today') + ->andWhere('s.dateId NOT IN (:activeXmlDateIds)') + ->setParameter('today', $today) + ->setParameter('activeXmlDateIds', $activeXmlDateIds) + ->getQuery() + ->execute(); + } } diff --git a/src/Service/TravelSnapshotService.php b/src/Service/TravelSnapshotService.php index 8961b93..61d76da 100644 --- a/src/Service/TravelSnapshotService.php +++ b/src/Service/TravelSnapshotService.php @@ -272,6 +272,19 @@ class TravelSnapshotService 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. */ diff --git a/tests/Service/TravelSnapshotServiceTest.php b/tests/Service/TravelSnapshotServiceTest.php index d365219..5e365c6 100644 --- a/tests/Service/TravelSnapshotServiceTest.php +++ b/tests/Service/TravelSnapshotServiceTest.php @@ -450,6 +450,41 @@ class TravelSnapshotServiceTest extends TestCase $this->assertSame(['processed' => 0, 'updated' => 0, 'failed' => 0], $result); } + public function testPurgeOrphanedFutureSnapshotsReturnsZeroForEmptyList(): void + { + $this->snapshotRepository + ->expects($this->once()) + ->method('deleteOrphanedFutureSnapshots') + ->with([], $this->isInstanceOf(\DateTimeImmutable::class)) + ->willReturn(0); + + $result = $this->service->purgeOrphanedFutureSnapshots([]); + + $this->assertSame(0, $result); + } + + public function testPurgeOrphanedFutureSnapshotsDelegatesWithCorrectArguments(): void + { + $activeIds = [100, 200, 300]; + + $this->snapshotRepository + ->expects($this->once()) + ->method('deleteOrphanedFutureSnapshots') + ->with( + $activeIds, + $this->callback(function (\DateTimeImmutable $date) { + $expected = new \DateTimeImmutable('today'); + + return abs($date->getTimestamp() - $expected->getTimestamp()) < 5; + }) + ) + ->willReturn(3); + + $result = $this->service->purgeOrphanedFutureSnapshots($activeIds); + + $this->assertSame(3, $result); + } + public function testGenerateMappingBuildsCorrectStructure(): void { $hotel = new Hotel();