feat: persist travel snapshots and refresh extended availability
This commit is contained in:
@@ -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="[email protected]"
|
||||
# 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=[email protected]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+134
-1
@@ -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": "[email protected]"
|
||||
}
|
||||
],
|
||||
"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": "[email protected]"
|
||||
}
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
flagception:
|
||||
features:
|
||||
travel_snapshot:
|
||||
env: FEATURE_TRAVEL_SNAPSHOT
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260321120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create travel_snapshot table for DB-primary travel persistence';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -36,14 +36,50 @@ class Booking
|
||||
public ?string $paymentType = null;
|
||||
public ?string $paymentLabel = null;
|
||||
public ?BankAccount $bankAccount = null;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $participantsStatus = [];
|
||||
|
||||
/**
|
||||
* @var array<int, PersonalData>
|
||||
*/
|
||||
public array $participants = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
public array $transportationServices = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
public array $additionalServices = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Room>
|
||||
*/
|
||||
public array $rooms = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
public array $pickups = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
public array $dropOffs = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Surcharge>
|
||||
*/
|
||||
public array $surcharges = [];
|
||||
|
||||
/**
|
||||
* @var array<string|int, Insurance>
|
||||
*/
|
||||
public array $insurances = [];
|
||||
public ?int $invoiceNumber = null;
|
||||
public ?float $totalPrice = null;
|
||||
|
||||
@@ -12,8 +12,23 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class CrmAttributes
|
||||
{
|
||||
/**
|
||||
* @var array<int, CrmSelectionGroup>|null
|
||||
*/
|
||||
public ?array $selectionGroups = null;
|
||||
|
||||
/**
|
||||
* @var array<int, CrmAction>|null
|
||||
*/
|
||||
public ?array $crmActions = null;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $hotelCodes = [];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $roles = [];
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ class CrmSelectionGroup
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $label = null;
|
||||
|
||||
/**
|
||||
* @var array<int, CrmSelection>|null
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public ?array $selections = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Extended availability details for one service from VERFUEGBARKEIT2.
|
||||
*
|
||||
* This model augments classic availability with optional timing/date/description
|
||||
* fields used to enrich persisted travel snapshots.
|
||||
*/
|
||||
class ExtendedAvailability
|
||||
{
|
||||
public ?int $serviceId = null;
|
||||
public ?string $status = null;
|
||||
public ?int $available = null;
|
||||
public ?float $price = null;
|
||||
public ?\DateTimeImmutable $dateFrom = null;
|
||||
public ?\DateTimeImmutable $dateTo = null;
|
||||
public ?string $timeFrom = null;
|
||||
public ?string $description = null;
|
||||
public ?int $ageFrom = null;
|
||||
public ?int $ageTo = null;
|
||||
public ?bool $mandatory = null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Response model for extended availability endpoint payloads.
|
||||
*/
|
||||
class ExtendedServiceAvailabilityResponse
|
||||
{
|
||||
/**
|
||||
* @param array<int, ExtendedAvailability> $services
|
||||
* @param array<string> $allowedBookingStatus
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $services,
|
||||
public readonly array $allowedBookingStatus = [],
|
||||
public readonly ?string $travelStatus = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ExtendedAvailability>
|
||||
*/
|
||||
public function getServices(): array
|
||||
{
|
||||
return $this->services;
|
||||
}
|
||||
}
|
||||
@@ -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<int, CrmSelectionGroup>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $selectionGroups = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $additionalServices = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public bool $additionalServicesMutable = true;
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $transportationServices = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public bool $transportationServicesMutable = true;
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $pickups = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $dropOffs = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public bool $pickupsMutable = true;
|
||||
|
||||
/**
|
||||
* @var array<int, Room>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $rooms = [];
|
||||
|
||||
@@ -108,7 +129,7 @@ class Travel
|
||||
public ?Guide $guide = null;
|
||||
|
||||
/**
|
||||
* @var array<Insurance> Available insurances for this travel package
|
||||
* @var array<string|int, Insurance> Available insurances for this travel package
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $insurances = [];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\ExtendedAvailability;
|
||||
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* Parser for VERFUEGBARKEIT2 service payloads.
|
||||
*
|
||||
* Extracts extended service-level attributes (dates, times, hints, constraints)
|
||||
* plus optional travel-level booking-status metadata.
|
||||
*/
|
||||
class ExtendedAvailabilitiesParser extends AbstractParser
|
||||
{
|
||||
/**
|
||||
* Parses extended availability services from API result node.
|
||||
*/
|
||||
public function parseServices(Crawler $result): ExtendedServiceAvailabilityResponse
|
||||
{
|
||||
$availabilities = [];
|
||||
|
||||
$result
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\TravelSnapshotService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn:refresh-travel-snapshot',
|
||||
description: 'Refreshes active travel snapshots using extended availability data.',
|
||||
)]
|
||||
/**
|
||||
* Console entrypoint for refreshing persisted travel snapshots.
|
||||
*
|
||||
* Runs extended availability enrichment in batch mode and triggers
|
||||
* retention cleanup in the same invocation.
|
||||
*/
|
||||
class BpnRefreshTravelSnapshotCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TravelSnapshotService $snapshotService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures refresh-control and cleanup options.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TravelSnapshotRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TravelSnapshotRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_snapshot_date_hotel', columns: ['date_id', 'hotel_id'])]
|
||||
#[ORM\Index(name: 'idx_snapshot_date_code', columns: ['date_code'])]
|
||||
#[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'])]
|
||||
class TravelSnapshot
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $dateId;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $hotelId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $dateCode = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $productId = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $productCode = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $hotelLabel = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $payload;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64)]
|
||||
private string $payloadHash;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $capturedAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $extendedRefreshedAt = null;
|
||||
|
||||
public function __construct(int $dateId, int $hotelId, string $payload, string $payloadHash)
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TravelSnapshot;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* Repository for querying and maintaining persisted travel snapshots.
|
||||
*
|
||||
* Provides focused lookup methods used by DB-primary travel loading,
|
||||
* mapping generation, extended refresh scheduling, and retention cleanup.
|
||||
*
|
||||
* @extends ServiceEntityRepository<TravelSnapshot>
|
||||
*/
|
||||
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<int, TravelSnapshot>
|
||||
*/
|
||||
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<int, TravelSnapshot>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to generate XML files mapping, fallback to snapshots only', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$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;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to generate files mapping', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\ExtendedAvailability;
|
||||
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DayTimeUtility;
|
||||
use App\Entity\TravelSnapshot;
|
||||
use App\Repository\TravelSnapshotRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* Application service for DB-backed travel snapshots.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Store and load Travel graphs as JSON payloads.
|
||||
* - Expose snapshot-derived mapping/product lookup helpers.
|
||||
* - Enrich snapshots with extended availability data.
|
||||
* - Purge expired snapshot records.
|
||||
*/
|
||||
class TravelSnapshotService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TravelSnapshotRepository $snapshotRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly int $retentionBufferDays = 14,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a snapshot exists for the given date/hotel combination.
|
||||
*/
|
||||
public function exists(int $dateId, ?int $hotelId = null): bool
|
||||
{
|
||||
return null !== $this->findSnapshot($dateId, $hotelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Travel aggregate from snapshot payload.
|
||||
*
|
||||
* Returns null when no snapshot exists or payload deserialization fails.
|
||||
*/
|
||||
public function loadTravel(int $dateId, ?int $hotelId = null): ?Travel
|
||||
{
|
||||
$snapshot = $this->findSnapshot($dateId, $hotelId);
|
||||
if (null === $snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$travel = $this->serializer->deserialize(
|
||||
$snapshot->getPayload(),
|
||||
Travel::class,
|
||||
'json'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Snapshot payload cannot be deserialized to Travel', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'snapshotId' => $snapshot->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
public function generateMapping(): array
|
||||
{
|
||||
$mapping = [];
|
||||
|
||||
foreach ($this->snapshotRepository->findAllForMapping() as $snapshot) {
|
||||
$dateId = $snapshot->getDateId();
|
||||
$hotelId = $snapshot->getHotelId();
|
||||
|
||||
if (false === isset($mapping[$dateId])) {
|
||||
$mapping[$dateId] = [
|
||||
'id' => $dateId,
|
||||
'code' => $snapshot->getDateCode(),
|
||||
'label' => $snapshot->getLabel(),
|
||||
'dateFrom' => $snapshot->getDateFrom(),
|
||||
'dateTo' => $snapshot->getDateTo(),
|
||||
'hotels' => [],
|
||||
'file' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$mapping[$dateId]['hotels'][$hotelId] = [
|
||||
'id' => $hotelId,
|
||||
'code' => $snapshot->getHotelCode(),
|
||||
'label' => $snapshot->getHotelLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds product ID for a travel date based on snapshot metadata.
|
||||
*/
|
||||
public function findProductIdByDateId(int $dateId): ?int
|
||||
{
|
||||
return $this->snapshotRepository->findProductIdByDateId($dateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes snapshot payloads with extended availability data.
|
||||
*
|
||||
* @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<int, ExtendedServiceAvailabilityResponse|null> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\XmlParser\ExtendedAvailabilitiesParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class ExtendedAvailabilitiesParserTest extends TestCase
|
||||
{
|
||||
private ExtendedAvailabilitiesParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new ExtendedAvailabilitiesParser();
|
||||
}
|
||||
|
||||
public function testParsesServicesWithAllAttributes(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen>
|
||||
<leistung id="101" status="Frei" frei="8" preis="29,90" termin="01.01.2030" bis="06.01.2030"
|
||||
uhrzeit_von="09:00" hinweis="Bitte anmelden" altervon="10" alterbis="65" pflicht="True" />
|
||||
</leistungen>
|
||||
<reise status="Frei" buchungstatusmoeglich="FA" />
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen>
|
||||
<leistung id="102" status="Frei" frei="0" preis="0,00" termin="01.01.2030" bis="06.01.2030"
|
||||
altervon="" alterbis="" pflicht="" />
|
||||
</leistungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
<reise status="Frei" buchungstatusmoeglich="FA" />
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
<reise status="Frei" buchungstatusmoeglich="" />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertSame([], $response->allowedBookingStatus);
|
||||
}
|
||||
|
||||
public function testMissingReiseNodeLeavesMetadataNullAndEmpty(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertNull($response->travelStatus);
|
||||
$this->assertSame([], $response->allowedBookingStatus);
|
||||
}
|
||||
|
||||
public function testEmptyServicesCollection(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertSame([], $response->getServices());
|
||||
}
|
||||
}
|
||||
@@ -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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise id="1" idbuspro="2187" code="SSTMR">
|
||||
<termin id="1" idbuspro="11946" termin="01.01.2030" bis="06.01.2030" reiseart="F">
|
||||
<text>Test Travel</text>
|
||||
<abpreis>659,00</abpreis>
|
||||
<hotel id="1" idbuspro="157047">
|
||||
<zimmer>
|
||||
<preis zimmercode="4erDW" idbuspro_zimmer="95" zimmertext="4er Zimmer" MinPax="4" MaxPax="4" naechte="5" preis="689,00" status="Frei" verfuegbar="8" />
|
||||
</zimmer>
|
||||
</hotel>
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>';
|
||||
|
||||
$crawler = new Crawler($xmlContent);
|
||||
$travelNode = $crawler->filterXPath('//reise/termin')->first();
|
||||
$travel = $this->parser->parse($travelNode);
|
||||
|
||||
$this->assertNull($travel->productId);
|
||||
}
|
||||
|
||||
public function testParseServiceWithoutDescriptions(): void
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Entity\TravelSnapshot;
|
||||
use App\Repository\TravelSnapshotRepository;
|
||||
use App\Service\TravelSnapshotService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class TravelSnapshotServiceTest extends TestCase
|
||||
{
|
||||
private TravelSnapshotRepository $snapshotRepository;
|
||||
private EntityManagerInterface $entityManager;
|
||||
private ApiClient $apiClient;
|
||||
private LoggerInterface $logger;
|
||||
private SerializerInterface $serializer;
|
||||
private TravelSnapshotService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user