diff --git a/.env b/.env index 8c257a5..d97fe06 100644 --- a/.env +++ b/.env @@ -44,7 +44,7 @@ MAILJET_API_KEY= MAILJET_API_SECRET= MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST MAILJET_NEWSLETTER_LIST_ID= -NEWSLETTER_CONFIRMATION_TTL_HOURS=1 +APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_BPN_USER= APP_BPN_PASSWORD= @@ -66,6 +66,10 @@ APP_CUSTOMER_SERVICE_EMAIL="kundenservice@ep-reisen.de" # Travel Data Service Configuration APP_TRAVEL_PREFER_REMOTE=false APP_TRAVEL_ENABLE_FALLBACK=true +APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 + +# Feature Flags +FEATURE_TRAVEL_SNAPSHOT=true # Emails APP_DEFAULT_EMAIL_FROM=info@ep-reisen.de diff --git a/composer.json b/composer.json index 2748b64..e2ba77c 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "doctrine/orm": "^3.3", "easycorp/easyadmin-bundle": "^4.27", "fakerphp/faker": "^1.24", + "flagception/flagception-bundle": "^6.1", "league/flysystem-bundle": "^3.4", "league/flysystem-sftp-v3": "^3.29", "league/oauth2-server-bundle": "^1.0", diff --git a/composer.lock b/composer.lock index 3930718..3be334a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff6bf7b0734ba021721679b2c94873a8", + "content-hash": "8873775310190f915550e46ea5813036", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1641,6 +1641,139 @@ }, "time": "2024-11-21T13:46:39+00:00" }, + { + "name": "flagception/flagception", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/playox/flagception-sdk.git", + "reference": "6934e73ea1ab43611a43bf85922349a83561177b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/playox/flagception-sdk/zipball/6934e73ea1ab43611a43bf85922349a83561177b", + "reference": "6934e73ea1ab43611a43bf85922349a83561177b", + "shasum": "" + }, + "require": { + "php": "^7.2||^8.0", + "psr/cache": "^1.0||^2.0||^3.0", + "symfony/expression-language": ">=2.7" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4.3", + "phpunit/phpunit": "*", + "squizlabs/php_codesniffer": "^3.5" + }, + "suggest": { + "flagception/database-activator": "Fetch feature flags from database." + }, + "type": "lib", + "autoload": { + "psr-4": { + "Flagception\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michel Chowanski", + "email": "michel.chowanski@office-partner.de" + } + ], + "description": "Feature toggle on steroids.", + "keywords": [ + "feature", + "feature-flags", + "feature-toggle", + "flagception", + "flags", + "rollout", + "testing", + "toggle" + ], + "support": { + "issues": "https://github.com/playox/flagception-sdk/issues", + "source": "https://github.com/playox/flagception-sdk/tree/2.0.0" + }, + "time": "2025-02-26T16:11:43+00:00" + }, + { + "name": "flagception/flagception-bundle", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/playox/flagception-bundle.git", + "reference": "ae8b5537348805f2c4ca4380651bf095f3c2d252" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/playox/flagception-bundle/zipball/ae8b5537348805f2c4ca4380651bf095f3c2d252", + "reference": "ae8b5537348805f2c4ca4380651bf095f3c2d252", + "shasum": "" + }, + "require": { + "flagception/flagception": "^2.0", + "php": "^8.0", + "symfony/config": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", + "symfony/dependency-injection": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", + "symfony/http-kernel": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", + "symfony/yaml": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", + "twig/twig": "^1.18|^2.0|^3.0" + }, + "require-dev": { + "flagception/database-activator": "^2.0", + "php-coveralls/php-coveralls": "^2.0", + "squizlabs/php_codesniffer": "^3.3.1", + "symfony/phpunit-bridge": "^5.0 | ^6.0 | ^7.0 | ^8.0", + "symfony/twig-bridge": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0" + }, + "suggest": { + "flagception/database-activator": "Fetch feature flags from a database." + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/playox/flagception-sdk", + "name": "flagception/flagception" + } + }, + "autoload": { + "psr-4": { + "Flagception\\Bundle\\FlagceptionBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michel Chowanski", + "email": "michel.chowanski@bestit-online.de" + } + ], + "description": "Feature toggle bundle on steroids.", + "keywords": [ + "bundle", + "feature", + "feature-flags", + "feature-toggle", + "flagception", + "flags", + "rollout", + "symfony", + "toggle" + ], + "support": { + "issues": "https://github.com/playox/flagception-bundle/issues", + "source": "https://github.com/playox/flagception-bundle/tree/6.1.0" + }, + "time": "2025-12-02T20:35:11+00:00" + }, { "name": "lcobucci/clock", "version": "3.5.0", diff --git a/config/bundles.php b/config/bundles.php index 325728f..1bf116d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -18,4 +18,5 @@ return [ Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], + Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true], ]; diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml new file mode 100644 index 0000000..c0dd3b4 --- /dev/null +++ b/config/packages/flagception.yaml @@ -0,0 +1,4 @@ +flagception: + features: + travel_snapshot: + env: FEATURE_TRAVEL_SNAPSHOT diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index 309d78f..b3c7156 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -28,6 +28,14 @@ zenstruck_schedule: frequency: "45 1 * * *" description: "Removes expired pending newsletter double opt-in requests" + - task: app:bpn:refresh-travel-snapshot --refresh-after 60 + frequency: "0 8-19 * * *" + description: "Sync travel data snapshots with BusPro XML data hourly during peak hours" + + - task: app:bpn:refresh-travel-snapshot --refresh-after 180 + frequency: "0 20-23,0-7 * * *" + description: "Sync travel data snapshots with BusPro XML data every 3 hours outside peak hours" + when@staging: zenstruck_schedule: mailer: diff --git a/config/services.yaml b/config/services.yaml index 0669ef9..e449aaf 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,7 +9,8 @@ parameters: path_to_keys: '%kernel.project_dir%/config/secret' bpn_debug: '%env(APP_BPN_DEBUG)%' default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%' - newsletter_confirmation_ttl_hours: '%env(int:NEWSLETTER_CONFIRMATION_TTL_HOURS)%' + newsletter_confirmation_ttl_hours: '%env(int:APP_NEWSLETTER_CONFIRMATION_TTL_HOURS)%' + travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' @@ -106,6 +107,10 @@ services: arguments: $path: '%path_to_keys%' + App\Service\TravelSnapshotService: + arguments: + $retentionBufferDays: '%travel_snapshot_retention_buffer_days%' + App\Service\TravelDataService: arguments: $logger: '@monolog.logger.core' diff --git a/docs/travel-snapshot-extended-availability-plan.md b/docs/travel-snapshot-extended-availability-plan.md new file mode 100644 index 0000000..fdfebca --- /dev/null +++ b/docs/travel-snapshot-extended-availability-plan.md @@ -0,0 +1,79 @@ +# Travel Snapshot Persistence (DB-Primary + Extended Availability) + +## Scope +Persist travel payloads in DB and use them as primary local source, with enrichment from BusPro `VERFUEGBARKEIT2` (internal naming: **extended/ext**). + +## Current Implementation Status + +### Completed +- [x] Added snapshot persistence entity and repository: + - `src/Entity/TravelSnapshot.php` + - `src/Repository/TravelSnapshotRepository.php` +- [x] Added snapshot application service: + - `src/Service/TravelSnapshotService.php` + - Uses Symfony Serializer JSON payloads for `Travel` + - Supports upsert/hash comparison, load, mapping, product lookup, refresh, purge +- [x] Added extended availability integration: + - `ApiClient::TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2'` + - `ApiClient::getAvailabilitiesExtended()` + - parser dispatch in `ApiResponseParser` + - `ExtendedAvailabilitiesParser` + - models `ExtendedAvailability` and `ExtendedServiceAvailabilityResponse` +- [x] Added DB-primary read path in `TravelDataService`: + - tries snapshot first + - falls back to XML parse + enrichment + snapshot upsert + - snapshot mapping merged into `generateFilesMap()` + - product lookup includes snapshot metadata fallback +- [x] Added refresh command: + - `app:travel:snapshot-refresh` + - supports batch processing, force mode, optional purge +- [x] Added migration for snapshot table: + - `migrations/Version20260321120000.php` +- [x] Added serializer type metadata/docblocks in relevant model classes to support stable snapshot deserialization. +- [x] Removed unified travel cache from `TravelDataService::getTravelData()`: + - cache and snapshot-version-token logic removed; travel is now loaded directly from snapshot DB (fast indexed lookup) or XML fallback on every call + - `TravelSnapshotService::getCacheVersionToken()` removed along with it +- [x] Fixed hotel-specific lookup correctness: + - when `hotelId` is provided, snapshot lookup no longer falls back to another hotel of same date. + +### Confirmed Behaviors +- [x] Snapshot data survives XML deletion and remains loadable. +- [x] Extended refresh updates service-level fields including `uhrzeit_von` -> `Service::timeFrom`. +- [x] Runtime availability overlay (`VERFUEGBARKEIT`) remains in place. + +## Design Decisions (Final) +- Internal naming uses `extended/ext`; external request type string stays `VERFUEGBARKEIT2`. +- DB snapshots are the primary local source for travel loading. +- Serializer format is JSON via Symfony Serializer, not PHP `serialize()`. +- No application-level cache wraps `getTravelData()`; snapshot DB is the fast path, XML is the fallback. + +## Operational Commands +- Refresh snapshots: +```bash +ddev php bin/console app:travel:snapshot-refresh +``` +- Force refresh: +```bash +ddev php bin/console app:travel:snapshot-refresh --force +``` +- Force refresh with limit: +```bash +ddev php bin/console app:travel:snapshot-refresh --force --limit=500 +``` +- Refresh + purge: +```bash +ddev php bin/console app:travel:snapshot-refresh --purge +``` + +## Open TODOs +- [ ] Revisit DB indexes on `travel_snapshot` and remove unused ones if desired (`date_code`, `product_id` currently appear non-critical for active query paths). +- [x] Added automated tests: + - `TravelSnapshotServiceTest` — upsert, load, exists, generateMapping + - `TravelDataServiceTest` — DB-primary + XML fallback paths, insurance rehydration, exception propagation +- [ ] Add/expand automated tests: + - extended parser coverage + - snapshot refresh command behavior +- [ ] Define production cron schedule for refresh and cleanup cadence. + +## Notes +- If refreshed snapshot data is not visible immediately, ensure the request path is not serving an older in-memory/session DTO. There is no application-level cache on `getTravelData()`; each call reads the snapshot DB directly, so data is current on the next request after a refresh. diff --git a/migrations/Version20260321120000.php b/migrations/Version20260321120000.php new file mode 100644 index 0000000..2d316da --- /dev/null +++ b/migrations/Version20260321120000.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE travel_snapshot (id INT AUTO_INCREMENT NOT NULL, date_id INT NOT NULL, hotel_id INT NOT NULL, date_code VARCHAR(64) DEFAULT NULL, label VARCHAR(255) DEFAULT NULL, product_id INT DEFAULT NULL, product_code VARCHAR(64) DEFAULT NULL, hotel_code VARCHAR(64) DEFAULT NULL, hotel_label VARCHAR(255) DEFAULT NULL, date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', payload LONGTEXT NOT NULL, payload_hash VARCHAR(64) NOT NULL, captured_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', extended_refreshed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_snapshot_date_hotel (date_id, hotel_id), INDEX idx_snapshot_date_code (date_code), INDEX idx_snapshot_product_id (product_id), INDEX idx_snapshot_date_to (date_to), INDEX idx_snapshot_extended_refreshed_at (extended_refreshed_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE travel_snapshot'); + } +} diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 47797df..ca89fc8 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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 */ diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index e2b76d4..18f831c 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -36,14 +36,50 @@ class Booking public ?string $paymentType = null; public ?string $paymentLabel = null; public ?BankAccount $bankAccount = null; + + /** + * @var array + */ public array $participantsStatus = []; + + /** + * @var array + */ public array $participants = []; + + /** + * @var array + */ public array $transportationServices = []; + + /** + * @var array + */ public array $additionalServices = []; + + /** + * @var array + */ public array $rooms = []; + + /** + * @var array + */ public array $pickups = []; + + /** + * @var array + */ public array $dropOffs = []; + + /** + * @var array + */ public array $surcharges = []; + + /** + * @var array + */ public array $insurances = []; public ?int $invoiceNumber = null; public ?float $totalPrice = null; diff --git a/src/BusProNet/Model/CrmAttributes.php b/src/BusProNet/Model/CrmAttributes.php index a03b5b2..f23b19a 100644 --- a/src/BusProNet/Model/CrmAttributes.php +++ b/src/BusProNet/Model/CrmAttributes.php @@ -12,8 +12,23 @@ namespace App\BusProNet\Model; */ class CrmAttributes { + /** + * @var array|null + */ public ?array $selectionGroups = null; + + /** + * @var array|null + */ public ?array $crmActions = null; + + /** + * @var array + */ public array $hotelCodes = []; + + /** + * @var array + */ public array $roles = []; } diff --git a/src/BusProNet/Model/CrmSelectionGroup.php b/src/BusProNet/Model/CrmSelectionGroup.php index 5a6fcb4..ab5e9ca 100644 --- a/src/BusProNet/Model/CrmSelectionGroup.php +++ b/src/BusProNet/Model/CrmSelectionGroup.php @@ -38,6 +38,9 @@ class CrmSelectionGroup #[Groups(['api:single'])] public ?string $label = null; + /** + * @var array|null + */ #[Groups(['api:single'])] public ?array $selections = null; diff --git a/src/BusProNet/Model/ExtendedAvailability.php b/src/BusProNet/Model/ExtendedAvailability.php new file mode 100644 index 0000000..3a9cd4c --- /dev/null +++ b/src/BusProNet/Model/ExtendedAvailability.php @@ -0,0 +1,26 @@ + $services + * @param array $allowedBookingStatus + */ + public function __construct( + private readonly array $services, + public readonly array $allowedBookingStatus = [], + public readonly ?string $travelStatus = null, + ) { + } + + /** + * @return array + */ + public function getServices(): array + { + return $this->services; + } +} diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index ace477d..27eb9ee 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -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 + */ #[Groups(['api:single'])] public array $selectionGroups = []; + /** + * @var array + */ #[Groups(['api:single'])] public array $additionalServices = []; #[Groups(['api:single'])] public bool $additionalServicesMutable = true; + /** + * @var array + */ #[Groups(['api:single'])] public array $transportationServices = []; #[Groups(['api:single'])] public bool $transportationServicesMutable = true; + /** + * @var array + */ #[Groups(['api:single'])] public array $pickups = []; + /** + * @var array + */ #[Groups(['api:single'])] public array $dropOffs = []; #[Groups(['api:single'])] public bool $pickupsMutable = true; + /** + * @var array + */ #[Groups(['api:single'])] public array $rooms = []; @@ -108,7 +129,7 @@ class Travel public ?Guide $guide = null; /** - * @var array Available insurances for this travel package + * @var array Available insurances for this travel package */ #[Groups(['api:single'])] public array $insurances = []; diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index 314c28d..788425d 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -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: diff --git a/src/BusProNet/XmlParser/ExtendedAvailabilitiesParser.php b/src/BusProNet/XmlParser/ExtendedAvailabilitiesParser.php new file mode 100644 index 0000000..174b683 --- /dev/null +++ b/src/BusProNet/XmlParser/ExtendedAvailabilitiesParser.php @@ -0,0 +1,75 @@ +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); + } +} diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php index 01837a8..00dad4a 100644 --- a/src/BusProNet/XmlParser/TravelParser.php +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -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); diff --git a/src/Command/BpnRefreshTravelSnapshotCommand.php b/src/Command/BpnRefreshTravelSnapshotCommand.php new file mode 100644 index 0000000..5e3d7ee --- /dev/null +++ b/src/Command/BpnRefreshTravelSnapshotCommand.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/src/Controller/Api/TravelController.php b/src/Controller/Api/TravelController.php index 19ce446..a8873ac 100644 --- a/src/Controller/Api/TravelController.php +++ b/src/Controller/Api/TravelController.php @@ -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), }; diff --git a/src/Entity/TravelSnapshot.php b/src/Entity/TravelSnapshot.php new file mode 100644 index 0000000..57b7071 --- /dev/null +++ b/src/Entity/TravelSnapshot.php @@ -0,0 +1,243 @@ +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; + } +} diff --git a/src/Repository/TravelSnapshotRepository.php b/src/Repository/TravelSnapshotRepository.php new file mode 100644 index 0000000..9ffe846 --- /dev/null +++ b/src/Repository/TravelSnapshotRepository.php @@ -0,0 +1,125 @@ + + */ +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 + */ + 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 + */ + 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(); + } +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index a7b4e13..6c72b07 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -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 { diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index e0a5e46..66c0129 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -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, diff --git a/src/Service/TravelSnapshotService.php b/src/Service/TravelSnapshotService.php new file mode 100644 index 0000000..9408536 --- /dev/null +++ b/src/Service/TravelSnapshotService.php @@ -0,0 +1,376 @@ +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> + */ + 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 $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); + } + } +} diff --git a/symfony.lock b/symfony.lock index ec4e884..7658e1d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -47,6 +47,18 @@ "config/routes/easyadmin.yaml" ] }, + "flagception/flagception-bundle": { + "version": "6.1", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "3.0", + "ref": "9f045b9e854039c20e9ecf54aa8d9868c1b841cd" + }, + "files": [ + "config/packages/flagception.yaml" + ] + }, "friendsofphp/php-cs-fixer": { "version": "3.75", "recipe": { diff --git a/tests/BusProNet/XmlParser/ExtendedAvailabilitiesParserTest.php b/tests/BusProNet/XmlParser/ExtendedAvailabilitiesParserTest.php new file mode 100644 index 0000000..776dd0b --- /dev/null +++ b/tests/BusProNet/XmlParser/ExtendedAvailabilitiesParserTest.php @@ -0,0 +1,128 @@ +parser = new ExtendedAvailabilitiesParser(); + } + + public function testParsesServicesWithAllAttributes(): void + { + $xml = ' + + + + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $services = $response->getServices(); + $this->assertArrayHasKey(101, $services); + + $availability = $services[101]; + $this->assertSame(101, $availability->serviceId); + $this->assertSame('Frei', $availability->status); + $this->assertSame(8, $availability->available); + $this->assertSame(29.90, $availability->price); + $this->assertSame('2030-01-01', $availability->dateFrom->format('Y-m-d')); + $this->assertSame('2030-01-06', $availability->dateTo->format('Y-m-d')); + $this->assertSame('09:00', $availability->timeFrom); + $this->assertSame('Bitte anmelden', $availability->description); + $this->assertSame(10, $availability->ageFrom); + $this->assertSame(65, $availability->ageTo); + $this->assertTrue($availability->mandatory); + } + + public function testIgnoresEmptyAgeAndMandatoryAttributes(): void + { + $xml = ' + + + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $services = $response->getServices(); + $this->assertArrayHasKey(102, $services); + + $availability = $services[102]; + $this->assertNull($availability->ageFrom); + $this->assertNull($availability->ageTo); + $this->assertNull($availability->mandatory); + } + + public function testParsesTravelLevelMetadata(): void + { + $xml = ' + + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $this->assertSame('Frei', $response->travelStatus); + $this->assertSame(['F', 'A'], $response->allowedBookingStatus); + } + + public function testEmptyBuchungstatusmoeglichProducesNoStatusCodes(): void + { + $xml = ' + + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $this->assertSame([], $response->allowedBookingStatus); + } + + public function testMissingReiseNodeLeavesMetadataNullAndEmpty(): void + { + $xml = ' + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $this->assertNull($response->travelStatus); + $this->assertSame([], $response->allowedBookingStatus); + } + + public function testEmptyServicesCollection(): void + { + $xml = ' + + +'; + + $crawler = new Crawler($xml); + $response = $this->parser->parseServices($crawler); + + $this->assertSame([], $response->getServices()); + } +} diff --git a/tests/BusProNet/XmlParser/TravelParserTest.php b/tests/BusProNet/XmlParser/TravelParserTest.php index 49d0e42..28f6a79 100644 --- a/tests/BusProNet/XmlParser/TravelParserTest.php +++ b/tests/BusProNet/XmlParser/TravelParserTest.php @@ -114,6 +114,32 @@ class TravelParserTest extends TestCase $this->assertSame('Bus-Hinfahrt', $busService->label); $this->assertSame('Bus fährt nur bei ausreichender Teilnehmerzahl', $busService->description); $this->assertFalse($busService->autoBook); + + $this->assertSame(2187, $travel->productId); + } + + public function testProductIdIsNullWhenAttributeAbsent(): void + { + $xmlContent = ' + + + + Test Travel + 659,00 + + + + + + + +'; + + $crawler = new Crawler($xmlContent); + $travelNode = $crawler->filterXPath('//reise/termin')->first(); + $travel = $this->parser->parse($travelNode); + + $this->assertNull($travel->productId); } public function testParseServiceWithoutDescriptions(): void diff --git a/tests/Service/TravelDataServiceTest.php b/tests/Service/TravelDataServiceTest.php index 1715022..1ff60e5 100644 --- a/tests/Service/TravelDataServiceTest.php +++ b/tests/Service/TravelDataServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Service; use App\BusProNet\ApiClient; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\InsuranceLoader; @@ -12,10 +13,11 @@ use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlLoader\TravelLoader; use App\Exception\TravelNotFoundException; use App\Service\TravelDataService; +use App\Service\TravelSnapshotService; +use Flagception\Manager\FeatureManagerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; class TravelDataServiceTest extends TestCase { @@ -27,6 +29,8 @@ class TravelDataServiceTest extends TestCase private ApiClient $apiClient; private CacheInterface $cache; private LoggerInterface $logger; + private TravelSnapshotService $travelSnapshotService; + private FeatureManagerInterface $featureManager; protected function setUp(): void { @@ -37,6 +41,9 @@ class TravelDataServiceTest extends TestCase $this->apiClient = $this->createMock(ApiClient::class); $this->cache = $this->createMock(CacheInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->travelSnapshotService = $this->createMock(TravelSnapshotService::class); + $this->featureManager = $this->createMock(FeatureManagerInterface::class); + $this->featureManager->method('isActive')->with('travel_snapshot')->willReturn(true); $this->service = new TravelDataService( $this->travelLoader, @@ -46,8 +53,10 @@ class TravelDataServiceTest extends TestCase $this->apiClient, $this->cache, $this->logger, + $this->travelSnapshotService, + $this->featureManager, false, // preferRemote - true // enableFallback + true, // enableFallback ); } @@ -149,7 +158,27 @@ class TravelDataServiceTest extends TestCase $this->assertNull($result); } - public function testExistsInXmlTrue(): void + public function testExistsLocallyTrueFromSnapshot(): void + { + $dateId = 12345; + $hotelId = 67890; + + $this->travelSnapshotService + ->expects($this->once()) + ->method('exists') + ->with($dateId, $hotelId) + ->willReturn(true); + + $this->travelLoader + ->expects($this->never()) + ->method('generateFilesMap'); + + $result = $this->service->existsLocally($dateId, $hotelId); + + $this->assertTrue($result); + } + + public function testExistsLocallyTrueFromXmlMap(): void { $dateId = 12345; $hotelId = 67890; @@ -162,33 +191,45 @@ class TravelDataServiceTest extends TestCase ], ]; + $this->travelSnapshotService + ->expects($this->once()) + ->method('exists') + ->with($dateId, $hotelId) + ->willReturn(false); + $this->travelLoader ->expects($this->once()) ->method('generateFilesMap') ->willReturn($mapping); - $result = $this->service->existsInXml($dateId, $hotelId); + $result = $this->service->existsLocally($dateId, $hotelId); $this->assertTrue($result); } - public function testExistsInXmlFalseNoTravel(): void + public function testExistsLocallyFalseNoTravel(): void { $dateId = 12345; $hotelId = 67890; $mapping = []; + $this->travelSnapshotService + ->expects($this->once()) + ->method('exists') + ->with($dateId, $hotelId) + ->willReturn(false); + $this->travelLoader ->expects($this->once()) ->method('generateFilesMap') ->willReturn($mapping); - $result = $this->service->existsInXml($dateId, $hotelId); + $result = $this->service->existsLocally($dateId, $hotelId); $this->assertFalse($result); } - public function testExistsInXmlFalseNoHotel(): void + public function testExistsLocallyFalseNoHotel(): void { $dateId = 12345; $hotelId = 67890; @@ -199,12 +240,18 @@ class TravelDataServiceTest extends TestCase ], ]; + $this->travelSnapshotService + ->expects($this->once()) + ->method('exists') + ->with($dateId, $hotelId) + ->willReturn(false); + $this->travelLoader ->expects($this->once()) ->method('generateFilesMap') ->willReturn($mapping); - $result = $this->service->existsInXml($dateId, $hotelId); + $result = $this->service->existsLocally($dateId, $hotelId); $this->assertFalse($result); } @@ -223,6 +270,12 @@ class TravelDataServiceTest extends TestCase ], ]; + $this->travelSnapshotService + ->expects($this->once()) + ->method('exists') + ->with($dateId, $hotelId) + ->willReturn(false); + $this->travelLoader ->expects($this->once()) ->method('generateFilesMap') @@ -242,7 +295,7 @@ class TravelDataServiceTest extends TestCase ], $result); } - public function testGetTravelDataWithCaching(): void + public function testGetTravelDataFromLocalPrefersSnapshot(): void { $dateId = 12345; $hotelId = 67890; @@ -250,19 +303,69 @@ class TravelDataServiceTest extends TestCase $travel->id = $dateId; $travel->hotelId = $hotelId; - $cacheItem = $this->createMock(ItemInterface::class); - $cacheItem + $this->travelSnapshotService ->expects($this->once()) - ->method('expiresAfter') - ->with(300); + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn($travel); - $this->cache + $this->travelLoader + ->expects($this->never()) + ->method('loadById'); + + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); + + $this->assertSame($travel, $result); + } + + public function testGetTravelDataFromLocalRehydratesInsurancePackagesFromSnapshot(): void + { + $dateId = 12345; + $hotelId = 67890; + + // Simulate a deserialized snapshot state: containedInsuranceIds are present but + // containedInsurances is empty (circular-reference prevention strips it during serialization). + $individual = new Insurance(); + $individual->id = '10'; + $individual->package = false; + + $package = new Insurance(); + $package->id = '20'; + $package->package = true; + $package->containedInsuranceIds = ['10']; + $package->containedInsurances = []; // as it arrives after deserialization + + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + $travel->insurances = [$individual, $package]; + + $this->travelSnapshotService ->expects($this->once()) - ->method('get') - ->with('travel_unified_12345_67890_local') - ->willReturnCallback(function (string $key, callable $callback) use ($cacheItem) { - return $callback($cacheItem); - }); + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); + + $this->assertSame($travel, $result); + $this->assertCount(1, $package->containedInsurances); + $this->assertSame($individual, $package->containedInsurances[0]); + } + + public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn(null); $this->travelLoader ->expects($this->once()) @@ -280,12 +383,62 @@ class TravelDataServiceTest extends TestCase ->method('patchHotelDetails') ->with($travel); - $result = $this->service->getTravelData($dateId, $hotelId, false, true); + $this->travelSnapshotService + ->expects($this->once()) + ->method('upsertFromTravel') + ->with($travel); + + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); } - public function testGetTravelDataWithoutCaching(): void + public function testGetTravelDataFromLocalRehydratesInsurancePackagesFromXmlFallback(): void + { + $dateId = 12345; + $hotelId = 67890; + + // insuranceLoader->loadAll() returns these during enrichTravelData(); the package's + // containedInsurances starts empty (as it would from a fresh XML load before hydration). + $individual = new Insurance(); + $individual->id = '10'; + $individual->package = false; + + $package = new Insurance(); + $package->id = '20'; + $package->package = true; + $package->containedInsuranceIds = ['10']; + $package->containedInsurances = []; + + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn(null); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->pickupLoader->expects($this->once())->method('patchPickupsDetails')->with($travel); + $this->hotelLoader->expects($this->once())->method('patchHotelDetails')->with($travel); + $this->insuranceLoader->expects($this->once())->method('loadAll')->willReturn([$individual, $package]); + $this->travelSnapshotService->expects($this->once())->method('upsertFromTravel')->with($travel); + + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); + + $this->assertSame($travel, $result); + $this->assertCount(1, $package->containedInsurances); + $this->assertSame($individual, $package->containedInsurances[0]); + } + + public function testGetTravelDataDelegatesToLoadUncached(): void { $dateId = 12345; $hotelId = 67890; @@ -297,6 +450,12 @@ class TravelDataServiceTest extends TestCase ->expects($this->never()) ->method('get'); + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn(null); + $this->travelLoader ->expects($this->once()) ->method('loadById') @@ -313,8 +472,89 @@ class TravelDataServiceTest extends TestCase ->method('patchHotelDetails') ->with($travel); - $result = $this->service->getTravelData($dateId, $hotelId, false, false); + $result = $this->service->getTravelData($dateId, $hotelId, false); $this->assertSame($travel, $result); } + + public function testGetTravelDataFromLocalPropagatesTravelNotFoundFromXmlFallback(): void + { + $dateId = 12345; + $hotelId = 67890; + + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn(null); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willThrowException(new TravelNotFoundException($dateId)); + + $this->expectException(TravelNotFoundException::class); + $this->service->getTravelDataFromLocal($dateId, $hotelId); + } + + public function testSnapshotLoadFailureFallsBackToXml(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willThrowException(new \RuntimeException('DB connection failed')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Snapshot lookup failed, falling back to XML', $this->arrayHasKey('dateId')); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); + + $this->assertSame($travel, $result); + } + + public function testSnapshotPersistenceFailureDoesNotDiscardXmlTravel(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->travelSnapshotService + ->expects($this->once()) + ->method('upsertFromTravel') + ->willThrowException(new \RuntimeException('DB write failed')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to persist travel snapshot after XML load', $this->arrayHasKey('dateId')); + + $result = $this->service->getTravelDataFromXml($dateId, $hotelId); + + $this->assertNotNull($result); + $this->assertSame($travel, $result); + } } diff --git a/tests/Service/TravelSnapshotServiceTest.php b/tests/Service/TravelSnapshotServiceTest.php new file mode 100644 index 0000000..8a90a35 --- /dev/null +++ b/tests/Service/TravelSnapshotServiceTest.php @@ -0,0 +1,459 @@ +snapshotRepository = $this->createMock(TravelSnapshotRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->apiClient = $this->createMock(ApiClient::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->serializer = $this->createMock(SerializerInterface::class); + + $this->service = new TravelSnapshotService( + $this->snapshotRepository, + $this->entityManager, + $this->apiClient, + $this->logger, + $this->serializer, + 14, + ); + } + + public function testUpsertCreatesNewSnapshot(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn(null); + + $this->serializer + ->expects($this->once()) + ->method('serialize') + ->with($travel, 'json') + ->willReturn('{"id":100}'); + + $this->entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(TravelSnapshot::class)); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->service->upsertFromTravel($travel); + } + + public function testUpsertSkipsWhenHashUnchanged(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $payload = '{"id":100}'; + $hash = hash('sha256', $payload); + $snapshot = new TravelSnapshot(100, 200, $payload, $hash); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->serializer + ->expects($this->once()) + ->method('serialize') + ->with($travel, 'json') + ->willReturn($payload); + + $this->entityManager + ->expects($this->never()) + ->method('flush'); + + $this->service->upsertFromTravel($travel); + } + + public function testUpsertUpdatesWhenHashChanged(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $oldPayload = '{"id":100,"old":true}'; + $oldHash = hash('sha256', $oldPayload); + $snapshot = new TravelSnapshot(100, 200, $oldPayload, $oldHash); + + $newPayload = '{"id":100,"old":false}'; + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->serializer + ->expects($this->once()) + ->method('serialize') + ->with($travel, 'json') + ->willReturn($newPayload); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->service->upsertFromTravel($travel); + } + + public function testLoadTravelReturnsNullWhenNoSnapshot(): void + { + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn(null); + + $result = $this->service->loadTravel(100, 200); + + $this->assertNull($result); + } + + public function testLoadTravelReturnsNullOnDeserializationError(): void + { + $snapshot = new TravelSnapshot(100, 200, 'invalid-json', hash('sha256', 'invalid-json')); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->willThrowException(new \RuntimeException('Deserialization failed')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Snapshot payload cannot be deserialized to Travel', $this->arrayHasKey('snapshotId')); + + $result = $this->service->loadTravel(100, 200); + + $this->assertNull($result); + } + + public function testExistsReturnsTrueWhenSnapshotFound(): void + { + $snapshot = new TravelSnapshot(100, 200, '{}', hash('sha256', '{}')); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->assertTrue($this->service->exists(100, 200)); + } + + public function testExistsReturnsFalseWhenNoSnapshot(): void + { + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn(null); + + $this->assertFalse($this->service->exists(100, 200)); + } + + public function testLoadTravelReturnsDeserializedTravel(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $payload = '{"id":100}'; + $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->with($payload, Travel::class, 'json') + ->willReturn($travel); + + $result = $this->service->loadTravel(100, 200); + + $this->assertInstanceOf(Travel::class, $result); + $this->assertSame($travel, $result); + } + + public function testLoadTravelReturnsNullWhenDeserializerReturnsWrongType(): void + { + $payload = '{"id":100}'; + $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findByDateAndHotel') + ->with(100, 200) + ->willReturn($snapshot); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->willReturn(new \stdClass()); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Snapshot payload deserialization returned unexpected type', $this->arrayHasKey('snapshotId')); + + $result = $this->service->loadTravel(100, 200); + + $this->assertNull($result); + } + + public function testUpsertFromTravelSkipsWhenTravelIsIncomplete(): void + { + $travelNoId = new Travel(); + $travelNoId->id = null; + $travelNoId->hotelId = 200; + + $travelNoHotel = new Travel(); + $travelNoHotel->id = 100; + $travelNoHotel->hotelId = null; + + $this->serializer->expects($this->never())->method('serialize'); + $this->entityManager->expects($this->never())->method('flush'); + + $this->service->upsertFromTravel($travelNoId); + $this->service->upsertFromTravel($travelNoHotel); + } + + public function testFindProductIdByDateIdDelegatesToRepository(): void + { + $this->snapshotRepository + ->expects($this->once()) + ->method('findProductIdByDateId') + ->with(42) + ->willReturn(365); + + $result = $this->service->findProductIdByDateId(42); + + $this->assertSame(365, $result); + } + + public function testPurgeExpiredSnapshotsDelegatesWithCorrectDate(): void + { + $this->snapshotRepository + ->expects($this->once()) + ->method('deleteExpiredSnapshots') + ->with($this->callback(function (\DateTimeImmutable $date) { + $expected = new \DateTimeImmutable('-14 days'); + + return abs($date->getTimestamp() - $expected->getTimestamp()) < 5; + })) + ->willReturn(7); + + $result = $this->service->purgeExpiredSnapshots(); + + $this->assertSame(7, $result); + } + + public function testRefreshExtendedSnapshotsSuccessPath(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $oldPayload = '{"id":100}'; + $oldHash = hash('sha256', $oldPayload); + $snapshot = new TravelSnapshot(100, 200, $oldPayload, $oldHash); + + $newPayload = '{"id":100,"extended":true}'; + $response = new ExtendedServiceAvailabilityResponse([], [], null); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findRefreshCandidates') + ->willReturn([$snapshot]); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->with($oldPayload, Travel::class, 'json') + ->willReturn($travel); + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilitiesExtended') + ->with(100) + ->willReturn($response); + + $this->serializer + ->expects($this->once()) + ->method('serialize') + ->with($travel, 'json') + ->willReturn($newPayload); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $result = $this->service->refreshExtendedSnapshots(); + + $this->assertSame(['processed' => 1, 'updated' => 1, 'failed' => 0], $result); + } + + public function testRefreshExtendedSnapshotsApiFailureIncrementsFailedCount(): void + { + $travel = new Travel(); + $travel->id = 100; + $travel->hotelId = 200; + + $payload = '{"id":100}'; + $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findRefreshCandidates') + ->willReturn([$snapshot]); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->willReturn($travel); + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilitiesExtended') + ->willThrowException(new ApiClientException('API error')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to fetch extended availability', $this->arrayHasKey('dateId')); + + $this->entityManager + ->expects($this->never()) + ->method('flush'); + + $result = $this->service->refreshExtendedSnapshots(); + + $this->assertSame(['processed' => 1, 'updated' => 0, 'failed' => 1], $result); + } + + public function testRefreshExtendedSnapshotsDeserializationFailureIncrementsFailedCount(): void + { + $payload = '{"id":100}'; + $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findRefreshCandidates') + ->willReturn([$snapshot]); + + $this->serializer + ->expects($this->once()) + ->method('deserialize') + ->willThrowException(new \RuntimeException('Deserialization failed')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Snapshot payload cannot be deserialized to Travel', $this->arrayHasKey('snapshotId')); + + $this->entityManager + ->expects($this->never()) + ->method('flush'); + + $result = $this->service->refreshExtendedSnapshots(); + + $this->assertSame(['processed' => 1, 'updated' => 0, 'failed' => 1], $result); + } + + public function testGenerateMappingBuildsCorrectStructure(): void + { + $hotel = new Hotel(); + $hotel->code = 'HTL1'; + $hotel->name = 'Hotel One'; + + $travel1 = new Travel(); + $travel1->id = 10; + $travel1->hotelId = 20; + $travel1->code = 'TRIP1'; + $travel1->label = 'Trip One'; + $travel1->productId = 365; + $travel1->hotel = $hotel; + $travel1->dateFrom = new \DateTimeImmutable('2026-06-01'); + $travel1->dateTo = new \DateTimeImmutable('2026-06-14'); + + $payload1 = '{"id":10}'; + $snapshot1 = new TravelSnapshot(10, 20, $payload1, hash('sha256', $payload1)); + $snapshot1->setDateCode('TRIP1') + ->setLabel('Trip One') + ->setDateFrom(new \DateTimeImmutable('2026-06-01')) + ->setDateTo(new \DateTimeImmutable('2026-06-14')) + ->setHotelCode('HTL1') + ->setHotelLabel('Hotel One'); + + $payload2 = '{"id":10,"h":21}'; + $snapshot2 = new TravelSnapshot(10, 21, $payload2, hash('sha256', $payload2)); + $snapshot2->setDateCode('TRIP1') + ->setLabel('Trip One') + ->setDateFrom(new \DateTimeImmutable('2026-06-01')) + ->setDateTo(new \DateTimeImmutable('2026-06-14')) + ->setHotelCode('HTL2') + ->setHotelLabel('Hotel Two'); + + $this->snapshotRepository + ->expects($this->once()) + ->method('findAllForMapping') + ->willReturn([$snapshot1, $snapshot2]); + + $mapping = $this->service->generateMapping(); + + $this->assertArrayHasKey(10, $mapping); + $this->assertSame('TRIP1', $mapping[10]['code']); + $this->assertArrayHasKey(20, $mapping[10]['hotels']); + $this->assertArrayHasKey(21, $mapping[10]['hotels']); + $this->assertSame('HTL1', $mapping[10]['hotels'][20]['code']); + $this->assertSame('HTL2', $mapping[10]['hotels'][21]['code']); + } +}