diff --git a/migrations/Version20260324120000.php b/migrations/Version20260324120000.php new file mode 100644 index 0000000..79b0b43 --- /dev/null +++ b/migrations/Version20260324120000.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE travel_snapshot CHANGE payload payload LONGTEXT NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload TEXT NOT NULL'); + } +} diff --git a/src/BusProNet/Model/CrmSelection.php b/src/BusProNet/Model/CrmSelection.php index df0729e..888f9b6 100644 --- a/src/BusProNet/Model/CrmSelection.php +++ b/src/BusProNet/Model/CrmSelection.php @@ -15,15 +15,15 @@ use Symfony\Component\Serializer\Attribute\Groups; */ class CrmSelection { - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?int $id = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?string $label = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $mutable = false; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $selected = false; } diff --git a/src/BusProNet/Model/CrmSelectionGroup.php b/src/BusProNet/Model/CrmSelectionGroup.php index ab5e9ca..7627296 100644 --- a/src/BusProNet/Model/CrmSelectionGroup.php +++ b/src/BusProNet/Model/CrmSelectionGroup.php @@ -32,16 +32,16 @@ class CrmSelectionGroup 90 => 'Transport', ]; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?int $id = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?string $label = null; /** * @var array|null */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?array $selections = null; /** diff --git a/src/BusProNet/Model/Guide.php b/src/BusProNet/Model/Guide.php index 7e72329..75c8f32 100644 --- a/src/BusProNet/Model/Guide.php +++ b/src/BusProNet/Model/Guide.php @@ -14,9 +14,9 @@ use Symfony\Component\Serializer\Attribute\Groups; */ class Guide { - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?string $name = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?string $phone = null; } diff --git a/src/BusProNet/Model/Hotel.php b/src/BusProNet/Model/Hotel.php index d80c692..755605b 100644 --- a/src/BusProNet/Model/Hotel.php +++ b/src/BusProNet/Model/Hotel.php @@ -15,24 +15,24 @@ use Symfony\Component\Serializer\Attribute\Groups; */ class Hotel { - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $id = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $code = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $name = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $city = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $street = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $phone = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $country = null; } diff --git a/src/BusProNet/Model/Pickup.php b/src/BusProNet/Model/Pickup.php index e592af6..9f66599 100644 --- a/src/BusProNet/Model/Pickup.php +++ b/src/BusProNet/Model/Pickup.php @@ -17,32 +17,32 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; */ class Pickup { - #[Groups(['api:list'])] + #[Groups(['api:list', 'snapshot'])] public ?int $id = null; - #[Groups(['api:list'])] + #[Groups(['api:list', 'snapshot'])] public ?string $code = null; - #[Groups(['api:list'])] + #[Groups(['api:list', 'snapshot'])] public ?string $city = null; - #[Groups(['api:list'])] + #[Groups(['api:list', 'snapshot'])] public ?string $postalCode = null; - #[Groups(['api:list'])] + #[Groups(['api:list', 'snapshot'])] public ?string $street = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'])] public ?\DateTimeImmutable $time = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?float $price = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?float $priceOutbound = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?float $priceInbound = null; #[Groups(['api:booking'])] diff --git a/src/BusProNet/Model/Room.php b/src/BusProNet/Model/Room.php index 73052b1..480aea0 100644 --- a/src/BusProNet/Model/Room.php +++ b/src/BusProNet/Model/Room.php @@ -20,19 +20,19 @@ class Room public const SELECTION_TYPE_BY_PAX = 'by_pax'; public const SELECTION_TYPE_BY_ROOM = 'by_room'; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $id = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $category = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $boardId = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $code = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $label = null; #[Groups(['booking'])] @@ -46,19 +46,19 @@ class Room #[Groups(['booking'])] public ?int $totalCount = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $minPax = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $maxPax = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $nights = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $status = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $available = null; #[Groups(['booking'])] @@ -67,7 +67,7 @@ class Room #[Groups(['booking'])] public array $mapping = []; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?float $price = null; #[Groups(['booking'])] diff --git a/src/BusProNet/Model/Service.php b/src/BusProNet/Model/Service.php index 00adb5b..c5c28ab 100644 --- a/src/BusProNet/Model/Service.php +++ b/src/BusProNet/Model/Service.php @@ -17,78 +17,78 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; */ class Service { - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $id = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $category = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $status = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public bool $mandatory = false; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public bool $autoBook = false; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $label = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] public ?\DateTimeImmutable $dateFrom = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $timeFrom = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $dayTime = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] public ?\DateTimeImmutable $dateTo = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $subType = null; #[Groups(['booking'])] public ?int $totalCount = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $mapping = []; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?float $price = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?float $totalPrice = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $individualPrice = []; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $available = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $source = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $direction = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $ageFrom = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $ageTo = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $birthYearFrom = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?int $birthYearTo = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $ageConstraintType = null; #[Groups(['api:single'])] @@ -97,7 +97,7 @@ class Service #[Groups(['api:single'])] public ?string $rawAgeConstraintData = null; - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public ?string $description = null; /** @@ -107,6 +107,6 @@ class Service * When false, this service's price is excluded when determining which insurance * tier is appropriate for a participant. */ - #[Groups(['api:single', 'api:list'])] + #[Groups(['api:single', 'api:list', 'snapshot'])] public bool $includeInInsuranceCalculation = true; } diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 27eb9ee..0b6ae5d 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -20,43 +20,43 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Travel { use SortByPriceTrait; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $id = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $code = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $productCode = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $productId = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $label = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $hotelId = null; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?Hotel $hotel = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] public ?\DateTimeImmutable $dateFrom = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] public ?\DateTimeImmutable $dateTo = null; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?string $type = null; /** * Travel booking status from BusProNet API. * Possible values: 'Frei', 'Anfrage', 'Buchungsstop'. */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?string $status = null; /** @@ -65,67 +65,67 @@ class Travel * * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $allowedBookingStatus = []; - #[Groups(['api:list', 'api:single'])] + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?float $priceFrom = null; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $selectionGroups = []; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $additionalServices = []; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $additionalServicesMutable = true; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $transportationServices = []; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $transportationServicesMutable = true; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $pickups = []; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $dropOffs = []; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $pickupsMutable = true; /** * @var array */ - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public array $rooms = []; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $roomsMutable = true; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $participantDataMutable = true; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public bool $participantCountMutable = true; - #[Groups(['api:single'])] + #[Groups(['api:single', 'snapshot'])] public ?Guide $guide = null; /** diff --git a/src/BusProNet/XmlLoader/InsuranceLoader.php b/src/BusProNet/XmlLoader/InsuranceLoader.php index 2006f97..89c78b1 100644 --- a/src/BusProNet/XmlLoader/InsuranceLoader.php +++ b/src/BusProNet/XmlLoader/InsuranceLoader.php @@ -26,7 +26,7 @@ class InsuranceLoader extends AbstractLoader { try { return $this->cache->get('bpn_insurances', function (ItemInterface $item) use ($filename) { - $item->expiresAfter(3 * 60 * 60); + $item->expiresAfter(24 * 60 * 60); $crawler = $this->loadXml($filename); diff --git a/src/Command/BpnXmlSyncCommand.php b/src/Command/BpnXmlSyncCommand.php index 49a73c5..9d03183 100644 --- a/src/Command/BpnXmlSyncCommand.php +++ b/src/Command/BpnXmlSyncCommand.php @@ -32,7 +32,6 @@ class BpnXmlSyncCommand extends Command 'bpn_travels_mapping', 'bpn_hotels', 'bpn_pickups', - 'bpn_insurances', ]; public function __construct( diff --git a/src/Entity/TravelSnapshot.php b/src/Entity/TravelSnapshot.php index 683a40b..e13cd98 100644 --- a/src/Entity/TravelSnapshot.php +++ b/src/Entity/TravelSnapshot.php @@ -62,8 +62,8 @@ class TravelSnapshot #[ORM\Column(type: 'date_immutable', nullable: true)] private ?\DateTimeImmutable $dateTo = null; - /** Full JSON-serialized Travel aggregate. */ - #[ORM\Column(type: 'text')] + /** Full serialized Travel aggregate stored as JSON text. */ + #[ORM\Column(type: 'text', columnDefinition: 'LONGTEXT NOT NULL')] private string $payload; /** SHA-256 of payload; compared before every write to skip no-op flushes. */ diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index 623bf0c..fd91dbf 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -146,6 +146,7 @@ class TravelDataService ?? $this->getTravelDataFromXml($dateId, $hotelId); if (null !== $travel) { + $this->patchInsuranceData($travel); $this->hydrateInsurancePackageRelationships($travel->insurances); } @@ -760,7 +761,6 @@ class TravelDataService try { $this->pickupLoader->patchPickupsDetails($travel); $this->hotelLoader->patchHotelDetails($travel); - $this->patchInsuranceData($travel); } catch (\Exception $e) { $this->logger->warning('Failed to enrich travel data', [ 'travelId' => $travel->id, diff --git a/src/Service/TravelSnapshotService.php b/src/Service/TravelSnapshotService.php index 2e8bac2..8961b93 100644 --- a/src/Service/TravelSnapshotService.php +++ b/src/Service/TravelSnapshotService.php @@ -102,7 +102,7 @@ class TravelSnapshotService return; } - $payload = $this->serializer->serialize($travel, 'json'); + $payload = $this->serializer->serialize($travel, 'json', ['groups' => ['snapshot']]); $payloadHash = hash('sha256', $payload); $snapshot = $this->snapshotRepository->findByDateAndHotel($travel->id, $travel->hotelId); @@ -241,7 +241,7 @@ class TravelSnapshotService $this->applyExtendedAvailability($travel, $response); - $payload = $this->serializer->serialize($travel, 'json'); + $payload = $this->serializer->serialize($travel, 'json', ['groups' => ['snapshot']]); $payloadHash = hash('sha256', $payload); if ($payloadHash !== $snapshot->getPayloadHash()) { $snapshot->setPayload($payload)->setPayloadHash($payloadHash); diff --git a/tests/Service/TravelDataServiceTest.php b/tests/Service/TravelDataServiceTest.php index a7b9bef..05b3762 100644 --- a/tests/Service/TravelDataServiceTest.php +++ b/tests/Service/TravelDataServiceTest.php @@ -313,13 +313,20 @@ class TravelDataServiceTest extends TestCase $this->assertSame($travel, $result); } - public function testGetTravelDataFromLocalRehydratesInsurancePackagesFromSnapshot(): void + public function testGetTravelDataFromLocalHydratesInsurancePackagesFromLoaderAfterSnapshotLoad(): void { $dateId = 12345; $hotelId = 67890; - // Simulate a deserialized snapshot state: containedInsuranceIds are present but - // containedInsurances is empty (circular-reference prevention strips it during serialization). + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + // Snapshot-provided insurances are intentionally replaced by InsuranceLoader data. + $staleSnapshotInsurance = new Insurance(); + $staleSnapshotInsurance->id = 'stale'; + $travel->insurances = [$staleSnapshotInsurance]; + $individual = new Insurance(); $individual->id = '10'; $individual->package = false; @@ -328,12 +335,7 @@ class TravelDataServiceTest extends TestCase $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]; + $package->containedInsurances = []; $this->travelSnapshotService ->expects($this->once()) @@ -341,9 +343,15 @@ class TravelDataServiceTest extends TestCase ->with($dateId, $hotelId) ->willReturn($travel); + $this->insuranceLoader + ->expects($this->once()) + ->method('loadAll') + ->willReturn([$individual, $package]); + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); + $this->assertSame([$individual, $package], array_values($travel->insurances)); $this->assertCount(1, $package->containedInsurances); $this->assertSame($individual, $package->containedInsurances[0]); } @@ -393,8 +401,8 @@ class TravelDataServiceTest extends TestCase $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). + // InsuranceLoader supplies the canonical insurance list for local reads; the package's + // containedInsurances starts empty and is rebuilt after hydration. $individual = new Insurance(); $individual->id = '10'; $individual->package = false;