From 346256f89558a83a9a700ba3c1df94ec54fcd1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 3 Apr 2026 18:32:18 +0200 Subject: [PATCH] feat: simplify booking pricing flow --- src/Service/BookingPriceCalculatorService.php | 372 ++++++++- src/Service/BookingSummaryDataService.php | 3 - src/Service/ServicePricingCalculator.php | 447 ----------- src/Service/TravelDataService.php | 756 +++++++----------- src/Service/TravelEnrichmentService.php | 79 ++ src/Service/TravelLookupService.php | 217 +++++ .../BookingPriceCalculatorServiceTest.php | 53 +- tests/Service/TravelDataServiceTest.php | 448 ++++------- tests/Service/TravelEnrichmentServiceTest.php | 156 ++++ tests/Service/TravelLookupServiceTest.php | 297 +++++++ 10 files changed, 1561 insertions(+), 1267 deletions(-) delete mode 100644 src/Service/ServicePricingCalculator.php create mode 100644 src/Service/TravelEnrichmentService.php create mode 100644 src/Service/TravelLookupService.php create mode 100644 tests/Service/TravelEnrichmentServiceTest.php create mode 100644 tests/Service/TravelLookupServiceTest.php diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index a702fdf..f69b1bb 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -4,20 +4,25 @@ declare(strict_types=1); namespace App\Service; +use App\BusProNet\Constants; +use App\BusProNet\Model\Insurance; +use App\BusProNet\Model\Service; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantDto; /** * Facade for booking pricing calculations. * * Provides comprehensive pricing calculations for the booking system by - * coordinating specialized calculators for rooms, services, and participants. + * coordinating room and participant calculations and handling service aggregation directly. * Returns structured pricing data for display in forms and summaries. */ class BookingPriceCalculatorService { public function __construct( private readonly RoomPricingCalculator $roomPricingCalculator, - private readonly ServicePricingCalculator $servicePricingCalculator, + private readonly ParticipantEligibilityService $participantEligibilityService, + private readonly InsuranceService $insuranceService, private readonly ParticipantPricingCalculator $participantPricingCalculator, ) { } @@ -35,7 +40,7 @@ class BookingPriceCalculatorService ): array { $roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode); - $servicePricing = $this->servicePricingCalculator->calculateServicePricing($bookingDto); + $servicePricing = $this->calculateServicePricing($bookingDto); $grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode); $result = [ @@ -82,11 +87,31 @@ class BookingPriceCalculatorService * * @param BookingDto $bookingDto The booking data containing participants and their service selections * - * @return array Array of service groups with each group containing services of the same subtype + * @return array> Array of service groups with each group containing services of the same subtype */ public function calculateServicePricing(BookingDto $bookingDto): array { - return $this->servicePricingCalculator->calculateServicePricing($bookingDto); + $participants = $bookingDto->getParticipants(); + + if ([] === $participants) { + return []; + } + + $serviceAggregation = []; + + foreach ($participants as $participantIndex => $participant) { + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + + $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); + } + + foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) { + $serviceAggregation[$key] = $transportationItem; + } + + return $this->groupServicesBySubtype($serviceAggregation); } /** @@ -102,7 +127,7 @@ class BookingPriceCalculatorService ): float { $roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode); - $serviceTotal = $this->servicePricingCalculator->calculateServiceTotal($bookingDto); + $serviceTotal = $this->calculateServiceTotal($bookingDto); return $roomTotal + $serviceTotal; } @@ -123,7 +148,14 @@ class BookingPriceCalculatorService */ public function calculateServiceTotal(BookingDto $bookingDto): float { - return $this->servicePricingCalculator->calculateServiceTotal($bookingDto); + $servicePricing = $this->calculateServicePricing($bookingDto); + + $total = 0.0; + foreach ($servicePricing as $group) { + $total += $group['groupTotal']; + } + + return $total; } /** @@ -175,6 +207,332 @@ class BookingPriceCalculatorService return '€'.$this->formatPrice($price); } + /** + * Aggregates all transportation-related services and pricing into separate line items. + * + * Creates separate entries for: + * - Befoerderung: Sum of all positive pickup prices and base transportation costs + * - Befoerderung - Rabatt: Sum of all negative transportation prices (discounts) + * - Parkplatz: Sum of all parking service prices + * + * Only includes transportation costs from eligible participants. + * + * @param BookingDto $bookingDto The booking data containing participants + * + * @return array> Array of transportation line items + */ + private function aggregateTransportationServices(BookingDto $bookingDto): array + { + $participants = $bookingDto->getParticipants(); + + $transportationPositiveTotal = 0.0; + $transportationDiscountTotal = 0.0; + $parkingTotal = 0.0; + + $transportationParticipants = 0; + $discountParticipants = 0; + $parkingParticipants = 0; + + foreach ($participants as $participantIndex => $participant) { + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + + $participantTransportationPositiveCost = 0.0; + $participantTransportationDiscountCost = 0.0; + $participantParkingCost = 0.0; + + if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { + if ($participant->transportationOutbound->price < 0) { + $participantTransportationDiscountCost += $participant->transportationOutbound->price; + } else { + $participantTransportationPositiveCost += $participant->transportationOutbound->price; + } + } + + if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { + if ($participant->transportationInbound->price < 0) { + $participantTransportationDiscountCost += $participant->transportationInbound->price; + } else { + $participantTransportationPositiveCost += $participant->transportationInbound->price; + } + } + + $hasOutboundBus = null !== $participant->transportationOutbound + && 'BUS' === $participant->transportationOutbound->subType; + + if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { + if ($participant->pickup->price < 0) { + $participantTransportationDiscountCost += $participant->pickup->price; + } else { + $participantTransportationPositiveCost += $participant->pickup->price; + } + } + + if (null !== $participant->dropOff && null !== $participant->dropOff->price) { + if ($participant->dropOff->price < 0) { + $participantTransportationDiscountCost += $participant->dropOff->price; + } else { + $participantTransportationPositiveCost += $participant->dropOff->price; + } + } + + if (null !== $participant->parkingService && null !== $participant->parkingService->price) { + $participantParkingCost += $participant->parkingService->price; + } + + if ($participantTransportationPositiveCost > 0) { + $transportationPositiveTotal += $participantTransportationPositiveCost; + ++$transportationParticipants; + } + if ($participantTransportationDiscountCost < 0) { + $transportationDiscountTotal += $participantTransportationDiscountCost; + ++$discountParticipants; + } + if ($participantParkingCost > 0) { + $parkingTotal += $participantParkingCost; + ++$parkingParticipants; + } + } + + $transportationItems = []; + + if ($transportationPositiveTotal > 0) { + $transportationItems['transportation_positive'] = [ + 'serviceId' => 'transportation_positive', + 'label' => 'Beförderung', + 'unitPrice' => null, + 'participantCount' => $transportationParticipants, + 'totalPrice' => $transportationPositiveTotal, + 'subType' => Constants::GROUP_TRANSPORTATION, + ]; + } + + if ($transportationDiscountTotal < 0) { + $transportationItems['transportation_discount'] = [ + 'serviceId' => 'transportation_discount', + 'label' => 'Beförderung - Rabatt', + 'unitPrice' => null, + 'participantCount' => $discountParticipants, + 'totalPrice' => $transportationDiscountTotal, + 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', + ]; + } + + if ($parkingTotal > 0) { + $transportationItems['transportation_parking'] = [ + 'serviceId' => 'transportation_parking', + 'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], + 'unitPrice' => null, + 'participantCount' => $parkingParticipants, + 'totalPrice' => $parkingTotal, + 'subType' => Constants::GROUP_TRANSPORTATION, + ]; + } + + return $transportationItems; + } + + /** + * Groups services by their subtypes for display, separating positive costs and discounts. + * + * @param array> $serviceAggregation + * + * @return array> + */ + private function groupServicesBySubtype(array $serviceAggregation): array + { + $groupedServices = []; + $servicesBySubtypeAndSign = []; + + foreach ($serviceAggregation as $serviceData) { + $subType = $serviceData['subType'] ?? 'other'; + if (null === $subType || '' === $subType) { + $subType = 'other'; + } + + if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { + $subType = Constants::GROUP_RENTALS; + } + + if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { + $subType = Constants::GROUP_INSURANCE; + } + + $isDiscount = $serviceData['totalPrice'] < 0; + $bucketKey = $subType.($isDiscount ? '_discount' : '_regular'); + + if (false === isset($servicesBySubtypeAndSign[$bucketKey])) { + $servicesBySubtypeAndSign[$bucketKey] = [ + 'subType' => $subType, + 'isDiscount' => $isDiscount, + 'services' => [], + 'total' => 0.0, + 'participantCount' => 0, + ]; + } + + $servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData; + $servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice']; + $servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount']; + } + + foreach ($servicesBySubtypeAndSign as $bucketData) { + $baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']); + $groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName; + $services = array_map( + fn (array $serviceData): array => $this->createServicePricingLineItem($serviceData), + $bucketData['services'] + ); + + $groupedServices[] = [ + 'groupName' => $groupName, + 'services' => $services, + 'groupTotal' => $bucketData['total'], + ]; + } + + return $groupedServices; + } + + private function getGroupNameForSubtype(string $subType): string + { + if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { + return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS]; + } + + return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen'; + } + + private function aggregateParticipantServices( + ParticipantDto $participant, + array &$serviceAggregation, + BookingDto $bookingDto, + ): void { + if (null !== $participant->skiPass && null !== $participant->skiPass->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->skiPass); + } + + if (null !== $participant->veg && null !== $participant->veg->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->veg); + } + + if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance); + } + + $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); + if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) { + $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate); + } + + $multipleServiceArrays = [ + 'courses' => $participant->courses, + 'additionalServices' => $participant->additionalServices, + 'board' => $participant->board, + 'rentals' => $participant->rentals, + ]; + + foreach ($multipleServiceArrays as $serviceArray) { + if (true === is_array($serviceArray)) { + foreach ($serviceArray as $service) { + if ($service instanceof Service && null !== $service->price) { + $this->addToServiceAggregation($serviceAggregation, $service); + } + } + } + } + } + + private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void + { + $serviceKey = $service->id.'_'.$service->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $service->id, + 'label' => $service->label, + 'unitPrice' => $service->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $service->subType, + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity; + } + + private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void + { + $serviceKey = $insurance->id.'_'.$insurance->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $insurance->id, + 'label' => $insurance->label, + 'unitPrice' => $insurance->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $insurance->getSubType(), + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; + } + + /** + * @param array $serviceData + * + * @return array + */ + private function createServicePricingLineItem(array $serviceData): array + { + return [ + 'serviceId' => $serviceData['serviceId'] ?? null, + 'label' => (string) ($serviceData['label'] ?? ''), + 'unitPrice' => array_key_exists('unitPrice', $serviceData) ? $serviceData['unitPrice'] : null, + 'participantCount' => (int) ($serviceData['participantCount'] ?? 0), + 'totalPrice' => (float) ($serviceData['totalPrice'] ?? 0.0), + 'subType' => $serviceData['subType'] ?? null, + ]; + } + + private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance + { + if (null !== $participant->insurance) { + return $participant->insurance; + } + + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { + return null; + } + + if (0 === $participant->index) { + return $participant->insurance; + } + + $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); + $sameTypeInsurances = $this->insuranceService->filterByType( + $selectableInsurances, + $applicant->insurance + ); + $travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( + $bookingDto, + $participant->index + ); + $eligibleInsurances = $this->insuranceService->getEligibleInsurances( + $sameTypeInsurances, + $participant, + $bookingDto, + $travelPrice + ); + + return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; + } + /** * Calculates the total price for an individual participant. * diff --git a/src/Service/BookingSummaryDataService.php b/src/Service/BookingSummaryDataService.php index a24565b..ecfebd9 100644 --- a/src/Service/BookingSummaryDataService.php +++ b/src/Service/BookingSummaryDataService.php @@ -45,9 +45,6 @@ class BookingSummaryDataService // Calculate individual prices for all participants $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto); - // Calculate total price - $totalPrice = array_sum($participantPrices); - // Get room assignment counts $roomCounts = []; foreach ($bookingDto->participants as $participant) { diff --git a/src/Service/ServicePricingCalculator.php b/src/Service/ServicePricingCalculator.php deleted file mode 100644 index 0871a4b..0000000 --- a/src/Service/ServicePricingCalculator.php +++ /dev/null @@ -1,447 +0,0 @@ -getParticipants(); - - if (true === empty($participants)) { - return []; - } - - // Aggregate service selections across all eligible participants - $serviceAggregation = []; - - foreach ($participants as $participantIndex => $participant) { - // Skip ineligible participants (no skipasses available for their age) - if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { - continue; - } - - $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); - } - - // Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz) - $transportationItems = $this->aggregateTransportationServices($bookingDto); - foreach ($transportationItems as $key => $transportationItem) { - $serviceAggregation[$key] = $transportationItem; - } - - // Group services by subtype and convert to pricing format - return $this->groupServicesBySubtype($serviceAggregation); - } - - /** - * Calculates total price for all services. - */ - public function calculateServiceTotal(BookingDto $bookingDto): float - { - $servicePricing = $this->calculateServicePricing($bookingDto); - - return array_sum(array_column($servicePricing, 'groupTotal')); - } - - /** - * Aggregates all transportation-related services and pricing into separate line items. - * - * Creates separate entries for: - * - Befoerderung: Sum of all positive pickup prices and base transportation costs - * - Befoerderung - Rabatt: Sum of all negative transportation prices (discounts) - * - Parkplatz: Sum of all parking service prices - * - * Only includes transportation costs from eligible participants. - * - * @param BookingDto $bookingDto The booking data containing participants - * - * @return array Array of transportation line items (Befoerderung, Rabatt, Parkplatz) - */ - private function aggregateTransportationServices(BookingDto $bookingDto): array - { - $participants = $bookingDto->getParticipants(); - - $transportationPositiveTotal = 0.0; // Positive transportation/pickup costs - $transportationDiscountTotal = 0.0; // Negative transportation prices (discounts) - $parkingTotal = 0.0; // Parking service costs - - $transportationParticipants = 0; - $discountParticipants = 0; - $parkingParticipants = 0; - - foreach ($participants as $participantIndex => $participant) { - // Skip ineligible participants (no skipasses available for their age) - if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { - continue; - } - - $participantTransportationPositiveCost = 0.0; - $participantTransportationDiscountCost = 0.0; - $participantParkingCost = 0.0; - - // Transportation service pricing (outbound) - if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { - if ($participant->transportationOutbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationOutbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationOutbound->price; - } - } - - // Transportation service pricing (inbound) - if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { - if ($participant->transportationInbound->price < 0) { - $participantTransportationDiscountCost += $participant->transportationInbound->price; - } else { - $participantTransportationPositiveCost += $participant->transportationInbound->price; - } - } - - // Pickup pricing - only charged when outbound transportation is BUS - // This matches current BusPro behavior where pickup price is only applied for outbound bus - // Note: priceOutbound/priceInbound are populated for future split pricing support when BusPro is updated - $hasOutboundBus = null !== $participant->transportationOutbound - && 'BUS' === $participant->transportationOutbound->subType; - - if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { - if ($participant->pickup->price < 0) { - $participantTransportationDiscountCost += $participant->pickup->price; - } else { - $participantTransportationPositiveCost += $participant->pickup->price; - } - } - - // Drop-off pricing (charged when inbound is BUS and a drop-off is selected) - if (null !== $participant->dropOff && null !== $participant->dropOff->price) { - if ($participant->dropOff->price < 0) { - $participantTransportationDiscountCost += $participant->dropOff->price; - } else { - $participantTransportationPositiveCost += $participant->dropOff->price; - } - } - - // Parking service pricing - if (null !== $participant->parkingService && null !== $participant->parkingService->price) { - $participantParkingCost += $participant->parkingService->price; - } - - // Aggregate participant totals - if ($participantTransportationPositiveCost > 0) { - $transportationPositiveTotal += $participantTransportationPositiveCost; - ++$transportationParticipants; - } - if ($participantTransportationDiscountCost < 0) { - $transportationDiscountTotal += $participantTransportationDiscountCost; - ++$discountParticipants; - } - if ($participantParkingCost > 0) { - $parkingTotal += $participantParkingCost; - ++$parkingParticipants; - } - } - - $transportationItems = []; - - // Add transportation entry (only positive costs) - if ($transportationPositiveTotal > 0) { - $transportationItems['transportation_positive'] = [ - 'serviceId' => 'transportation_positive', - 'label' => 'Beförderung', - 'unitPrice' => null, - 'participantCount' => $transportationParticipants, - 'totalPrice' => $transportationPositiveTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, - ]; - } - - // Add discount entry (only negative costs) - if ($transportationDiscountTotal < 0) { - $transportationItems['transportation_discount'] = [ - 'serviceId' => 'transportation_discount', - 'label' => 'Beförderung - Rabatt', - 'unitPrice' => null, - 'participantCount' => $discountParticipants, - 'totalPrice' => $transportationDiscountTotal, - 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', - ]; - } - - // Add parking entry (only if positive costs) - if ($parkingTotal > 0) { - $transportationItems['transportation_parking'] = [ - 'serviceId' => 'transportation_parking', - 'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], - 'unitPrice' => null, - 'participantCount' => $parkingParticipants, - 'totalPrice' => $parkingTotal, - 'subType' => Constants::GROUP_TRANSPORTATION, - ]; - } - - return $transportationItems; - } - - /** - * Groups services by their subtypes for display, separating positive costs and discounts. - * - * Creates separate entries for positive costs and negative costs (discounts) within each service group. - * For example: "Kurse" and "Kurse - Rabatt" if there are both positive and negative priced course services. - * - * @param array $serviceAggregation Aggregated service data - * - * @return array Grouped services by subtype with separate discount entries - */ - private function groupServicesBySubtype(array $serviceAggregation): array - { - $groupedServices = []; - - // First pass: separate positive and negative prices by subtype - $servicesBySubtypeAndSign = []; - - foreach ($serviceAggregation as $serviceData) { - $subType = $serviceData['subType'] ?? 'other'; - - // Normalize rental subtypes to avoid duplicate sections - if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { - $subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key - } - - // Normalize insurance subtypes to avoid duplicate sections - if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { - $subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key - } - - $isDiscount = $serviceData['totalPrice'] < 0; - - // Create separate buckets for positive costs and discounts - $bucketKey = $subType.($isDiscount ? '_discount' : '_regular'); - - if (false === isset($servicesBySubtypeAndSign[$bucketKey])) { - $servicesBySubtypeAndSign[$bucketKey] = [ - 'subType' => $subType, - 'isDiscount' => $isDiscount, - 'services' => [], - 'total' => 0.0, - 'participantCount' => 0, - ]; - } - - $servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData; - $servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice']; - $servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount']; - } - - // Second pass: create display groups - foreach ($servicesBySubtypeAndSign as $bucketData) { - $baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']); - $groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName; - - $groupedServices[] = [ - 'groupName' => $groupName, - 'services' => $bucketData['services'], - 'groupTotal' => $bucketData['total'], - ]; - } - - return $groupedServices; - } - - /** - * Maps service subtypes to user-friendly group names. - */ - private function getGroupNameForSubtype(string $subType): string - { - // Handle rentals array (keep for backward compatibility with non-normalized subtypes) - if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { - return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS]; - } - - return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen'; - } - - /** - * Aggregates service selections from a single participant into the service aggregation array. - */ - private function aggregateParticipantServices( - ParticipantDto $participant, - array &$serviceAggregation, - BookingDto $bookingDto, - ): void { - // Handle single service selections (skiPass, veg, rentalInsurance) - if (null !== $participant->skiPass && null !== $participant->skiPass->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->skiPass); - } - - if (null !== $participant->veg && null !== $participant->veg->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->veg); - } - - if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance); - } - - // Resolve insurance: use price-tier-adjusted insurance for bulk assignment - // Skip synthetic "no insurance" option - it's a UI construct that shouldn't appear in summary - $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); - - if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) { - $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate); - } - - // Handle multiple service selections - $multipleServiceArrays = [ - 'courses' => $participant->courses, - 'additionalServices' => $participant->additionalServices, - 'board' => $participant->board, - 'rentals' => $participant->rentals, - ]; - - foreach ($multipleServiceArrays as $serviceArray) { - if (true === is_array($serviceArray)) { - foreach ($serviceArray as $service) { - if ($service instanceof Service && null !== $service->price) { - $this->addToServiceAggregation($serviceAggregation, $service); - } - } - } - } - } - - /** - * Adds a service to the aggregation array, incrementing count and updating total price. - */ - private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void - { - $serviceKey = $service->id.'_'.$service->label; - - if (false === isset($serviceAggregation[$serviceKey])) { - $serviceAggregation[$serviceKey] = [ - 'serviceId' => $service->id, - 'label' => $service->label, - 'unitPrice' => $service->price, - 'participantCount' => 0, - 'totalPrice' => 0.0, - 'subType' => $service->subType, - ]; - } - - $serviceAggregation[$serviceKey]['participantCount'] += $quantity; - $serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity; - } - - /** - * Adds an insurance to the service aggregation array. - * - * @param array $serviceAggregation The service aggregation array to update - * @param Insurance $insurance The insurance to add - * @param int $quantity The quantity of the insurance - */ - private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void - { - $serviceKey = $insurance->id.'_'.$insurance->label; - - if (false === isset($serviceAggregation[$serviceKey])) { - $serviceAggregation[$serviceKey] = [ - 'serviceId' => $insurance->id, - 'label' => $insurance->label, - 'unitPrice' => $insurance->price, - 'participantCount' => 0, - 'totalPrice' => 0.0, - 'subType' => $insurance->getSubType(), - ]; - } - - $serviceAggregation[$serviceKey]['participantCount'] += $quantity; - $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; - } - - /** - * Resolves the insurance to use for aggregation, handling bulk insurance with price tiers. - * - * When bulk insurance is active, dependent participants get price-tier-adjusted insurance - * based on their individual travel price, matching the logic used in API submission. - * - * @param ParticipantDto $participant The participant to resolve insurance for - * @param BookingDto $bookingDto The booking context - * - * @return Insurance|null The insurance to aggregate (null if none) - */ - private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance - { - // If participant already has insurance assigned, use it - if (null !== $participant->insurance) { - return $participant->insurance; - } - - // Check if bulk insurance is active - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { - return null; // No bulk insurance active - } - - // Applicant always uses their own insurance - if (0 === $participant->index) { - return $participant->insurance; - } - - // For dependent participants: calculate price-tier-adjusted insurance - // This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive() - // Get selectable (non-complementary) insurances with caching - $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - - // Get insurances of the same type as applicant's selection - $sameTypeInsurances = $this->insuranceService->filterByType( - $selectableInsurances, - $applicant->insurance - ); - - // Calculate travel price for eligibility checks - $travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( - $bookingDto, - $participant->index - ); - - // Get eligible insurances for THIS participant (price tier adjusted) - $eligibleInsurances = $this->insuranceService->getEligibleInsurances( - $sameTypeInsurances, - $participant, - $bookingDto, - $travelPrice - ); - - // Return first eligible insurance (sorted by price) - return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; - } -} diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index ca54430..46eb216 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -10,9 +10,6 @@ use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Notification; use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; -use App\BusProNet\XmlLoader\HotelLoader; -use App\BusProNet\XmlLoader\InsuranceLoader; -use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlLoader\TravelLoader; use App\Exception\HotelNotFoundException; use App\Exception\HotelNotInTravelException; @@ -29,6 +26,9 @@ use Symfony\Contracts\Cache\ItemInterface; * 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. + * + * Mapping/lookup operations are delegated to TravelLookupService. + * Travel enrichment (XML details, insurances) is delegated to TravelEnrichmentService. */ class TravelDataService { @@ -37,23 +37,23 @@ class TravelDataService private const int AVAILABILITY_CACHE_TTL = 600; private const int MUTABILITY_CACHE_TTL = 300; - /** @var array>|null */ - private ?array $filesMapCache = null; - public function __construct( private readonly TravelLoader $travelLoader, - private readonly HotelLoader $hotelLoader, - private readonly PickupLoader $pickupLoader, - private readonly InsuranceLoader $insuranceLoader, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, private readonly TravelSnapshotService $travelSnapshotService, + private readonly TravelLookupService $travelLookupService, + private readonly TravelEnrichmentService $travelEnrichmentService, private readonly bool $preferRemote = false, private readonly bool $enableFallback = true, ) { } + // ------------------------------------------------------------------------- + // Travel loading + // ------------------------------------------------------------------------- + /** * Retrieve travel data with automatic source selection and fallback. * @@ -78,19 +78,21 @@ class TravelDataService * Retrieve travel data specifically from XML files. * * Loads travel data from local XML files with full data enrichment including - * hotel details and pickup information. + * hotel details and pickup information, then persists a snapshot. * * @param int $dateId The travel date ID to retrieve * @param int|null $hotelId Optional hotel ID for specific hotel data * - * @return Travel|null The travel data or null if not found in XML + * @return Travel|null The travel data or null on unexpected failure + * + * @throws TravelNotFoundException When the travel date is not found + * @throws HotelNotFoundException When the requested hotel is not found + * @throws HotelNotInTravelException When the hotel does not belong to this travel */ public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel { try { $travel = $this->travelLoader->loadById($dateId, $hotelId); - - $this->enrichTravelData($travel); } catch (TravelNotFoundException $e) { $this->logger->debug('Travel not found in XML', [ 'dateId' => $dateId, @@ -115,16 +117,20 @@ 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(), - ]); + // Only persist a fully-enriched snapshot. A partial enrichment (failed pickup/hotel + // loaders) must not overwrite an existing good snapshot with incomplete data. + $enriched = $this->travelEnrichmentService->enrichFromXml($travel); + + if ($enriched) { + 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', [ @@ -140,7 +146,8 @@ class TravelDataService * 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. + * when no snapshot payload is available. Insurance data is always refreshed from + * the XML loader to replace any stale snapshot values. */ public function getTravelDataFromLocal(int $dateId, ?int $hotelId = null): ?Travel { @@ -148,18 +155,73 @@ class TravelDataService ?? $this->getTravelDataFromXml($dateId, $hotelId); if (null !== $travel) { - $this->patchInsuranceData($travel); + $this->travelEnrichmentService->patchInsurances($travel); } return $travel; } - private function getTravelDataFromSnapshot(int $dateId, ?int $hotelId): ?Travel + /** + * Retrieve travel data specifically from remote API. + * + * Loads travel data from the remote BusProNet API. Note that the API uses + * product IDs rather than date IDs, so mapping is performed internally. + * + * @param int $dateId The travel date ID to retrieve + * @param int|null $hotelId Optional hotel ID for specific hotel data + * + * @return Travel|null The travel data or null if not found via API + */ + public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel { try { - $travel = $this->travelSnapshotService->loadTravel($dateId, $hotelId); - } catch (\Throwable $e) { - $this->logger->warning('Snapshot lookup failed, falling back to XML', [ + $productId = $this->travelLookupService->mapDateIdToProductId($dateId); + if (null === $productId) { + $this->logger->debug('Cannot map dateId to productId for API call', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + ]); + + return null; + } + + $result = $this->apiClient->getTravelData($productId, $hotelId); + + if (!$result instanceof Travel) { + $this->logger->debug('API returned non-travel result', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'productId' => $productId, + 'resultType' => get_class($result), + ]); + + return null; + } + + $this->logger->debug('Travel data loaded from API', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'productId' => $productId, + 'travelId' => $result->id, + ]); + + $this->travelEnrichmentService->patchInsurances($result); + + // Persist snapshot separately: a DB/serializer failure must not discard a + // successfully fetched remote travel or make source=remote unreliable. + try { + $this->travelSnapshotService->upsertFromTravel($result); + } catch (\Throwable $e) { + $this->logger->warning('Failed to persist travel snapshot after API load', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'error' => $e->getMessage(), + ]); + } + + return $result; + } catch (ApiClientException $e) { + $this->logger->error('Failed to load travel data from API', [ 'dateId' => $dateId, 'hotelId' => $hotelId, 'error' => $e->getMessage(), @@ -167,18 +229,6 @@ class TravelDataService 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; } /** @@ -231,71 +281,111 @@ class TravelDataService return ['processed' => $processed, 'failed' => $failed]; } + // ------------------------------------------------------------------------- + // Mutability + // ------------------------------------------------------------------------- + /** - * Retrieve travel data specifically from remote API. + * Gets mutability data for a travel date. * - * Loads travel data from the remote BusProNet API. Note that the API uses - * product IDs rather than date IDs, so mapping is performed internally. + * @param int $dateId The travel date ID for API call + * @param bool $cached Whether to use cached data (default: true, TTL: 5 minutes) + * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) * - * @param int $dateId The travel date ID to retrieve - * @param int|null $hotelId Optional hotel ID for specific hotel data - * - * @return Travel|null The travel data or null if not found via API + * @return BaseData|null The mutability data or null if not available or error occurred */ - public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel + public function getMutabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?BaseData { - try { - // Map dateId to productId for API call - $productId = $this->mapDateIdToProductId($dateId); - if (null === $productId) { - $this->logger->debug('Cannot map dateId to productId for API call', [ - 'dateId' => $dateId, - 'hotelId' => $hotelId, - ]); - - return null; - } - - $result = $this->apiClient->getTravelData($productId, $hotelId); - - if (!$result instanceof Travel) { - $this->logger->debug('API returned non-travel result', [ - 'dateId' => $dateId, - 'hotelId' => $hotelId, - 'productId' => $productId, - 'resultType' => get_class($result), - ]); - - return null; - } - - $this->logger->debug('Travel data loaded from API', [ - 'dateId' => $dateId, - 'hotelId' => $hotelId, - 'productId' => $productId, - 'travelId' => $result->id, - ]); - - $this->patchInsuranceData($result); - $this->travelSnapshotService->upsertFromTravel($result); - - return $result; - } catch (ApiClientException $e) { - $this->logger->error('Failed to load travel data from API', [ - 'dateId' => $dateId, - 'hotelId' => $hotelId, - 'error' => $e->getMessage(), - ]); - - return null; + if ($cached) { + return $this->fetchCached( + sprintf('mutability_%d', $dateId), + self::MUTABILITY_CACHE_TTL, + fn () => $this->fetchMutabilityData($dateId), + $forceRefresh, + ); } + + return $this->fetchMutabilityData($dateId); } /** - * Check if travel data exists in local persisted sources. + * Apply mutability data to travel services. * - * Performs a lightweight check to determine if travel data can be served from - * local sources without loading the full travel object. + * @param Travel $travel The travel object to update + * @param BaseData $mutableData The mutability configuration data + */ + public function patchMutability(Travel $travel, BaseData $mutableData): void + { + $this->travelLoader->patchMutability($travel, $mutableData); + + $this->logger->debug('Successfully patched mutability data', [ + 'travelId' => $travel->id, + ]); + } + + // ------------------------------------------------------------------------- + // Availability + // ------------------------------------------------------------------------- + + /** + * Gets availability data for a travel date. + * + * @param int $dateId The travel date ID for API call + * @param bool $cached Whether to use cached data (default: false) + * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) + * + * @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred + */ + public function getAvailabilityData(int $dateId, bool $cached = false, bool $forceRefresh = false): ?ServiceAvailabilityResponse + { + if ($cached) { + return $this->fetchCached( + sprintf('availability_%d', $dateId), + self::AVAILABILITY_CACHE_TTL, + fn () => $this->fetchAvailabilityData($dateId), + $forceRefresh, + ); + } + + return $this->fetchAvailabilityData($dateId); + } + + /** + * Apply availability data to travel services. + * + * @param Travel $travel The travel object to update + * @param ServiceAvailabilityResponse $availabilities The availability data for services + */ + public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void + { + $this->travelLoader->patchAvailabilities($travel, $availabilities); + + $this->logger->debug('Successfully patched availability data', [ + 'travelId' => $travel->id, + ]); + } + + /** + * Enriches travel data with fresh availability information from the API. + * + * @param Travel $travel The travel object to enrich + * @param bool $cached Whether to use cached availability data (default: true) + */ + public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void + { + $availabilities = $this->getAvailabilityData($travel->id, $cached); + + if (null !== $availabilities) { + $this->patchAvailabilities($travel, $availabilities); + } + } + + // ------------------------------------------------------------------------- + // Lookup delegates — public API preserved for backward compatibility + // ------------------------------------------------------------------------- + + /** + * Check if travel data exists in local persisted sources. * * @param int $dateId The travel date ID to check * @param int|null $hotelId Optional hotel ID for specific hotel data @@ -304,30 +394,12 @@ class TravelDataService */ 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])) { - return false; - } - - // If hotelId is specified, check if it exists in the travel's hotels - if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) { - return false; - } - - return true; + return $this->travelLookupService->existsLocally($dateId, $hotelId); } /** * Get information about available data sources for a travel. * - * 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 * @param int|null $hotelId Optional hotel ID for specific hotel data * @@ -335,37 +407,97 @@ class TravelDataService */ public function getAvailableSources(int $dateId, ?int $hotelId = null): array { - return [ - static::SOURCE_LOCAL => $this->existsLocally($dateId, $hotelId), - static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId), - ]; + return $this->travelLookupService->getAvailableSources($dateId, $hotelId); } /** - * Load travel data directly without caching. + * Map date code to date ID. * - * Internal method that handles the actual loading logic with fallback support. - * Tries the preferred source first, then falls back to the alternative if enabled. + * @param string $dateCode The date code to map * - * @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 local sources - * - * @return Travel|null The travel data or null if not found + * @return int|null The corresponding date ID or null if not found */ + public function mapDateCodeToId(string $dateCode): ?int + { + return $this->travelLookupService->mapDateCodeToId($dateCode); + } + + /** + * Map hotel code to hotel ID. + * + * @param string $hotelCode The hotel code to map + * + * @return int|null The corresponding hotel ID or null if not found + */ + public function mapHotelCodeToId(string $hotelCode): ?int + { + return $this->travelLookupService->mapHotelCodeToId($hotelCode); + } + + /** + * Map date ID to product ID for API calls. + * + * @param int $dateId The date ID to map + * + * @return int|null The corresponding product ID or null if not found + */ + public function mapDateIdToProductId(int $dateId): ?int + { + return $this->travelLookupService->mapDateIdToProductId($dateId); + } + + /** + * Generate files mapping for available travel data. + * + * @return array> Array mapping of travel data files + */ + public function generateFilesMap(): array + { + return $this->travelLookupService->generateFilesMap(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private function getTravelDataFromSnapshot(int $dateId, ?int $hotelId): ?Travel + { + 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; + } + private function loadTravelDataUncached(int $dateId, ?int $hotelId = null, bool $preferRemote = false): ?Travel { $primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL; $fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE; - // Try primary source first $travel = $this->loadFromSource($dateId, $hotelId, $primarySource); if (null !== $travel) { return $travel; } - // Try fallback source if enabled if (true === $this->enableFallback) { $this->logger->debug('Fallback to alternative source', [ 'dateId' => $dateId, @@ -380,17 +512,6 @@ class TravelDataService return $travel; } - /** - * Load travel data from a specific source. - * - * Internal method that routes to the appropriate loader based on source type. - * - * @param int $dateId The travel date ID to retrieve - * @param int|null $hotelId Optional hotel ID for specific hotel data - * @param string $source The source type (SOURCE_LOCAL or SOURCE_REMOTE) - * - * @return Travel|null The travel data or null if not found - */ private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel { return match ($source) { @@ -400,207 +521,6 @@ class TravelDataService }; } - /** - * Map date code to date ID. - * - * Converts a date code string to its corresponding date ID using the - * date loader's mapping functionality. - * - * @param string $dateCode The date code to map - * - * @return int|null The corresponding date ID or null if not found - */ - public function mapDateCodeToId(string $dateCode): ?int - { - try { - $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 (int) $dateId; - } catch (\Exception $e) { - $this->logger->error('Failed to map date code to ID', [ - 'dateCode' => $dateCode, - 'error' => $e->getMessage(), - ]); - - return null; - } - } - - /** - * Map hotel code to hotel ID. - * - * Converts a hotel code string to its corresponding hotel ID using the - * hotel loader's mapping functionality. - * - * @param string $hotelCode The hotel code to map - * - * @return int|null The corresponding hotel ID or null if not found - */ - public function mapHotelCodeToId(string $hotelCode): ?int - { - try { - $hotelId = $this->hotelLoader->mapCodeToId($hotelCode); - - $this->logger->debug('Hotel code mapping', [ - 'hotelCode' => $hotelCode, - 'hotelId' => $hotelId, - ]); - - return $hotelId; - } catch (\Exception $e) { - $this->logger->error('Failed to map hotel code to ID', [ - 'hotelCode' => $hotelCode, - 'error' => $e->getMessage(), - ]); - - return null; - } - } - - /** - * Map date ID to product ID for API calls. - * - * Converts a date ID to its corresponding product ID for use with the - * remote API. This method exposes the existing loader functionality - * through the service layer. - * - * @param int $dateId The date ID to map - * - * @return int|null The corresponding product ID or null if not found - */ - public function mapDateIdToProductId(int $dateId): ?int - { - try { - $productId = $this->travelSnapshotService->findProductIdByDateId($dateId); - $productId = $productId ?? $this->travelLoader->mapDateIdToProductId($dateId); - - $this->logger->debug('Date ID to product ID mapping', [ - 'dateId' => $dateId, - 'productId' => $productId, - ]); - - return $productId; - } catch (\Exception $e) { - $this->logger->error('Failed to map date ID to product ID', [ - 'dateId' => $dateId, - 'error' => $e->getMessage(), - ]); - - return null; - } - } - - /** - * Generate files mapping for available travel data. - * - * Creates a mapping of all available travel data files with their - * corresponding travel and hotel information. This method exposes - * the existing loader functionality through the service layer. - * - * @return array> Array mapping of travel data files - */ - public function generateFilesMap(): array - { - if (null !== $this->filesMapCache) { - return $this->filesMapCache; - } - - $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), - ]); - - $this->filesMapCache = $mapping; - - return $mapping; - } - - /** - * Fetch mutability data from API. - * - * Retrieves mutability configuration data from the API for a specific travel date. - * Handles API errors and notification responses gracefully. - * - * @param int $dateId The travel date ID for API call - * - * @return BaseData|null The mutability data or null if not available or error occurred - */ - /** - * Gets mutability data for a travel date. - * - * @param int $dateId The travel date ID for API call - * @param bool $cached Whether to use cached data (default: true, TTL: 5 minutes) - * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) - * - * @return BaseData|null The mutability data or null if not available or error occurred - */ - public function getMutabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?BaseData - { - if ($cached) { - $cacheKey = sprintf('mutability_%d', $dateId); - - try { - if (true === $forceRefresh) { - $this->cache->delete($cacheKey); - } - - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) { - $item->expiresAfter(self::MUTABILITY_CACHE_TTL); - - return $this->fetchMutabilityData($dateId); - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Cache error in getMutabilityData', [ - 'dateId' => $dateId, - 'error' => $e->getMessage(), - ]); - - // Fallback to direct API call - return $this->fetchMutabilityData($dateId); - } - } - - return $this->fetchMutabilityData($dateId); - } - - /** - * Fetches mutability data directly from the API without caching. - */ private function fetchMutabilityData(int $dateId): ?BaseData { try { @@ -631,65 +551,6 @@ class TravelDataService } } - /** - * Apply mutability data to travel services. - * - * Updates the mutability status of various travel services based on the - * provided mutability configuration data. - * - * @param Travel $travel The travel object to update - * @param BaseData $mutableData The mutability configuration data - */ - public function patchMutability(Travel $travel, BaseData $mutableData): void - { - $this->travelLoader->patchMutability($travel, $mutableData); - - $this->logger->debug('Successfully patched mutability data', [ - 'travelId' => $travel->id, - ]); - } - - /** - * Gets availability data for a travel date. - * - * @param int $dateId The travel date ID for API call - * @param bool $cached Whether to use cached data (default: false) - * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) - * - * @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred - */ - public function getAvailabilityData(int $dateId, bool $cached = false, bool $forceRefresh = false): ?ServiceAvailabilityResponse - { - if ($cached) { - $cacheKey = sprintf('availability_%d', $dateId); - - try { - if ($forceRefresh) { - $this->cache->delete($cacheKey); - } - - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) { - $item->expiresAfter(self::AVAILABILITY_CACHE_TTL); - - return $this->fetchAvailabilityData($dateId); - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Cache error in getAvailabilityData', [ - 'dateId' => $dateId, - 'error' => $e->getMessage(), - ]); - - // Fallback to direct API call - return $this->fetchAvailabilityData($dateId); - } - } - - return $this->fetchAvailabilityData($dateId); - } - - /** - * Fetches availability data directly from the API without caching. - */ private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse { try { @@ -721,85 +582,36 @@ class TravelDataService } /** - * Apply availability data to travel services. + * Fetch a value from cache, computing it via $fetcher on a miss. * - * Updates the availability status of additional and transportation - * services, and the allowed booking status, based on the provided - * availability data. + * On cache error the value is computed directly so callers are never blocked. + * Pass $forceRefresh=true to delete the cached entry before fetching. * - * @param Travel $travel The travel object to update - * @param ServiceAvailabilityResponse $availabilities The availability data for services + * @template T + * + * @param callable(): T $fetcher + * + * @return T|null */ - public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void - { - $this->travelLoader->patchAvailabilities($travel, $availabilities); - - $this->logger->debug('Successfully patched availability data', [ - 'travelId' => $travel->id, - ]); - } - - /** - * Enriches travel data with fresh availability information from the API. - * - * Fetches cached availability data and patches it onto the travel object. - * Used by controllers to ensure availability data is up-to-date before - * rendering forms or processing submissions. - * - * @param Travel $travel The travel object to enrich - * @param bool $cached Whether to use cached availability data (default: true) - */ - public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void - { - $availabilities = $this->getAvailabilityData($travel->id, $cached); - - if (null !== $availabilities) { - $this->patchAvailabilities($travel, $availabilities); - } - } - - /** - * Enrich travel data with additional information. - * - * Adds hotel details and pickup information to travel data loaded from XML. - * This enrichment is necessary for complete travel information. - * - * @param Travel $travel The travel object to enrich - */ - private function enrichTravelData(Travel $travel): void + private function fetchCached(string $cacheKey, int $ttl, callable $fetcher, bool $forceRefresh): mixed { try { - $this->pickupLoader->patchPickupsDetails($travel); - $this->hotelLoader->patchHotelDetails($travel); - } catch (\Exception $e) { - $this->logger->warning('Failed to enrich travel data', [ - 'travelId' => $travel->id, + if ($forceRefresh) { + $this->cache->delete($cacheKey); + } + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($ttl, $fetcher) { + $item->expiresAfter($ttl); + + return $fetcher(); + }); + } catch (InvalidArgumentException $e) { + $this->logger->error('Cache error', [ + 'key' => $cacheKey, 'error' => $e->getMessage(), ]); + + return $fetcher(); } } - - /** - * Adds insurance data to travel object. - * - * Loads all available insurances and adds them to the travel object. - * This ensures insurance options are available for booking. - * - * @param Travel $travel The travel object to enrich with insurance data - */ - private function patchInsuranceData(Travel $travel): void - { - try { - $insurances = $this->insuranceLoader->loadAll(); - // Keep insurances indexed by ID for efficient lookups - $travel->insurances = $insurances; - - } catch (\Exception $e) { - $this->logger->warning('Failed to load insurance data', [ - 'travelId' => $travel->id, - 'error' => $e->getMessage(), - ]); - } - } - } diff --git a/src/Service/TravelEnrichmentService.php b/src/Service/TravelEnrichmentService.php new file mode 100644 index 0000000..ebbd35d --- /dev/null +++ b/src/Service/TravelEnrichmentService.php @@ -0,0 +1,79 @@ +pickupLoader->patchPickupsDetails($travel); + $this->hotelLoader->patchHotelDetails($travel); + + return true; + } catch (\Exception $e) { + $this->logger->warning('Failed to enrich travel data', [ + 'travelId' => $travel->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Replace travel insurances with fresh data from the XML insurance loader. + * + * Always overwrites any existing insurances — including stale snapshot data — so + * callers receive fully-hydrated Insurance objects with resolved containedInsurances + * as populated by InsuranceParser at parse time. + * + * @param Travel $travel The travel object whose insurances will be replaced + */ + public function patchInsurances(Travel $travel): void + { + try { + $travel->insurances = $this->insuranceLoader->loadAll(); + } catch (\Exception $e) { + $this->logger->warning('Failed to load insurance data', [ + 'travelId' => $travel->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Service/TravelLookupService.php b/src/Service/TravelLookupService.php new file mode 100644 index 0000000..a17dd78 --- /dev/null +++ b/src/Service/TravelLookupService.php @@ -0,0 +1,217 @@ +>|null */ + private ?array $filesMapCache = null; + + public function __construct( + private readonly TravelLoader $travelLoader, + private readonly HotelLoader $hotelLoader, + private readonly TravelSnapshotService $travelSnapshotService, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Generate the combined files mapping from XML and snapshot sources. + * + * XML entries take precedence; snapshot entries fill in any gaps. The result is + * memoized per request so multiple callers within the same request pay no extra cost. + * + * @return array> + */ + public function generateFilesMap(): array + { + if (null !== $this->filesMapCache) { + return $this->filesMapCache; + } + + $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), + ]); + + $this->filesMapCache = $mapping; + + return $mapping; + } + + /** + * Convert a date code string to its corresponding date ID. + * + * @param string $dateCode The date code to resolve + * + * @return int|null The date ID, or null if the code is not found + */ + public function mapDateCodeToId(string $dateCode): ?int + { + try { + $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 (int) $dateId; + } catch (\Exception $e) { + $this->logger->error('Failed to map date code to ID', [ + 'dateCode' => $dateCode, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Convert a hotel code string to its corresponding hotel ID. + * + * @param string $hotelCode The hotel code to resolve + * + * @return int|null The hotel ID, or null if the code is not found + */ + public function mapHotelCodeToId(string $hotelCode): ?int + { + try { + $hotelId = $this->hotelLoader->mapCodeToId($hotelCode); + + $this->logger->debug('Hotel code mapping', [ + 'hotelCode' => $hotelCode, + 'hotelId' => $hotelId, + ]); + + return $hotelId; + } catch (\Exception $e) { + $this->logger->error('Failed to map hotel code to ID', [ + 'hotelCode' => $hotelCode, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Convert a date ID to its corresponding product ID for API calls. + * + * Prefers the product ID stored in the snapshot (fast DB lookup) before + * falling back to the XML loader's filename-based resolution. + * + * @param int $dateId The date ID to resolve + * + * @return int|null The product ID, or null if it cannot be determined + */ + public function mapDateIdToProductId(int $dateId): ?int + { + try { + $productId = $this->travelSnapshotService->findProductIdByDateId($dateId); + $productId = $productId ?? $this->travelLoader->mapDateIdToProductId($dateId); + + $this->logger->debug('Date ID to product ID mapping', [ + 'dateId' => $dateId, + 'productId' => $productId, + ]); + + return $productId; + } catch (\Exception $e) { + $this->logger->error('Failed to map date ID to product ID', [ + 'dateId' => $dateId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Check whether travel data exists in any local persisted source. + * + * Performs a lightweight existence check: the snapshot DB is consulted first + * and the XML files map is only scanned if no snapshot is found. + * + * @param int $dateId The travel date ID to check + * @param int|null $hotelId Optional hotel ID + * + * @return bool True if data exists locally + */ + 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])) { + return false; + } + + if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) { + return false; + } + + return true; + } + + /** + * Return availability flags for both local and remote sources. + * + * @param int $dateId The travel date ID to check + * @param int|null $hotelId Optional hotel ID + * + * @return array{local: bool, remote: bool} + */ + public function getAvailableSources(int $dateId, ?int $hotelId = null): array + { + return [ + 'local' => $this->existsLocally($dateId, $hotelId), + 'remote' => null !== $this->mapDateIdToProductId($dateId), + ]; + } +} diff --git a/tests/Service/BookingPriceCalculatorServiceTest.php b/tests/Service/BookingPriceCalculatorServiceTest.php index d261773..fb18668 100644 --- a/tests/Service/BookingPriceCalculatorServiceTest.php +++ b/tests/Service/BookingPriceCalculatorServiceTest.php @@ -16,38 +16,24 @@ use App\Service\InsuranceService; use App\Service\ParticipantEligibilityService; use App\Service\ParticipantPricingCalculator; use App\Service\RoomPricingCalculator; -use App\Service\ServicePricingCalculator; use PHPUnit\Framework\TestCase; class BookingPriceCalculatorServiceTest extends TestCase { private BookingPriceCalculatorService $service; private ParticipantEligibilityService $participantEligibilityService; + private RoomPricingCalculator $roomPricingCalculator; + private ParticipantPricingCalculator $participantPricingCalculator; protected function setUp(): void { $this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); $this->participantEligibilityService->method('isParticipantEligible')->willReturn(true); - // Build the calculator chain - $roomPricingCalculator = new RoomPricingCalculator(); - $participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator); + $this->roomPricingCalculator = new RoomPricingCalculator(); + $this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator); - // Create InsuranceService with a mock price calculator (to avoid circular reference in tests) - $mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class); - $insuranceService = new InsuranceService($mockPriceCalculator); - - $servicePricingCalculator = new ServicePricingCalculator( - $this->participantEligibilityService, - $insuranceService, - $participantPricingCalculator - ); - - $this->service = new BookingPriceCalculatorService( - $roomPricingCalculator, - $servicePricingCalculator, - $participantPricingCalculator - ); + $this->service = $this->createService($this->participantEligibilityService); } public function testCalculateRoomPricingWithParticipantBasedCalculation(): void @@ -613,24 +599,7 @@ class BookingPriceCalculatorServiceTest extends TestCase $participantEligibilityService->method('isParticipantEligible') ->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible - // Build the calculator chain with the custom eligibility service - $roomPricingCalculator = new RoomPricingCalculator(); - $participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator); - - $mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class); - $insuranceService = new InsuranceService($mockPriceCalculator); - - $servicePricingCalculator = new ServicePricingCalculator( - $participantEligibilityService, - $insuranceService, - $participantPricingCalculator - ); - - $this->service = new BookingPriceCalculatorService( - $roomPricingCalculator, - $servicePricingCalculator, - $participantPricingCalculator - ); + $this->service = $this->createService($participantEligibilityService); $result = $this->service->calculateServicePricing($bookingDto); @@ -725,4 +694,14 @@ class BookingPriceCalculatorServiceTest extends TestCase return $participant; } + + private function createService(ParticipantEligibilityService $participantEligibilityService): BookingPriceCalculatorService + { + return new BookingPriceCalculatorService( + $this->roomPricingCalculator, + $participantEligibilityService, + new InsuranceService(), + $this->participantPricingCalculator, + ); + } } diff --git a/tests/Service/TravelDataServiceTest.php b/tests/Service/TravelDataServiceTest.php index dba858f..cf1f48a 100644 --- a/tests/Service/TravelDataServiceTest.php +++ b/tests/Service/TravelDataServiceTest.php @@ -5,14 +5,12 @@ 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; -use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlLoader\TravelLoader; use App\Exception\TravelNotFoundException; use App\Service\TravelDataService; +use App\Service\TravelEnrichmentService; +use App\Service\TravelLookupService; use App\Service\TravelSnapshotService; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -22,34 +20,31 @@ class TravelDataServiceTest extends TestCase { private TravelDataService $service; private TravelLoader $travelLoader; - private HotelLoader $hotelLoader; - private PickupLoader $pickupLoader; - private InsuranceLoader $insuranceLoader; private ApiClient $apiClient; private CacheInterface $cache; private LoggerInterface $logger; private TravelSnapshotService $travelSnapshotService; + private TravelLookupService $travelLookupService; + private TravelEnrichmentService $travelEnrichmentService; protected function setUp(): void { $this->travelLoader = $this->createMock(TravelLoader::class); - $this->hotelLoader = $this->createMock(HotelLoader::class); - $this->pickupLoader = $this->createMock(PickupLoader::class); - $this->insuranceLoader = $this->createMock(InsuranceLoader::class); $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->travelLookupService = $this->createMock(TravelLookupService::class); + $this->travelEnrichmentService = $this->createMock(TravelEnrichmentService::class); $this->service = new TravelDataService( $this->travelLoader, - $this->hotelLoader, - $this->pickupLoader, - $this->insuranceLoader, $this->apiClient, $this->cache, $this->logger, $this->travelSnapshotService, + $this->travelLookupService, + $this->travelEnrichmentService, false, // preferRemote true, // enableFallback ); @@ -69,15 +64,45 @@ class TravelDataServiceTest extends TestCase ->with($dateId, $hotelId) ->willReturn($travel); - $this->pickupLoader + $this->travelEnrichmentService ->expects($this->once()) - ->method('patchPickupsDetails') + ->method('enrichFromXml') + ->with($travel) + ->willReturn(true); + + $this->travelSnapshotService + ->expects($this->once()) + ->method('upsertFromTravel') ->with($travel); - $this->hotelLoader + $result = $this->service->getTravelDataFromXml($dateId, $hotelId); + + $this->assertSame($travel, $result); + } + + public function testGetTravelDataFromXmlSkipsSnapshotWhenEnrichmentFails(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelLoader ->expects($this->once()) - ->method('patchHotelDetails') - ->with($travel); + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->travelEnrichmentService + ->expects($this->once()) + ->method('enrichFromXml') + ->with($travel) + ->willReturn(false); + + $this->travelSnapshotService + ->expects($this->never()) + ->method('upsertFromTravel'); $result = $this->service->getTravelDataFromXml($dateId, $hotelId); @@ -95,13 +120,9 @@ class TravelDataServiceTest extends TestCase ->with($dateId, $hotelId) ->willThrowException(new TravelNotFoundException($dateId)); - $this->pickupLoader + $this->travelEnrichmentService ->expects($this->never()) - ->method('patchPickupsDetails'); - - $this->hotelLoader - ->expects($this->never()) - ->method('patchHotelDetails'); + ->method('enrichFromXml'); $this->expectException(TravelNotFoundException::class); $this->service->getTravelDataFromXml($dateId, $hotelId); @@ -116,7 +137,7 @@ class TravelDataServiceTest extends TestCase $travel->id = $dateId; $travel->hotelId = $hotelId; - $this->travelLoader + $this->travelLookupService ->expects($this->once()) ->method('mapDateIdToProductId') ->with($dateId) @@ -128,17 +149,60 @@ class TravelDataServiceTest extends TestCase ->with($productId, $hotelId) ->willReturn($travel); + $this->travelEnrichmentService + ->expects($this->once()) + ->method('patchInsurances') + ->with($travel); + + $this->travelSnapshotService + ->expects($this->once()) + ->method('upsertFromTravel') + ->with($travel); + $result = $this->service->getTravelDataFromApi($dateId, $hotelId); $this->assertSame($travel, $result); } + public function testGetTravelDataFromApiSnapshotFailureDoesNotDiscardApiTravel(): void + { + $dateId = 12345; + $hotelId = 67890; + $productId = 555; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelLookupService + ->method('mapDateIdToProductId') + ->willReturn($productId); + + $this->apiClient + ->method('getTravelData') + ->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 API load', $this->arrayHasKey('dateId')); + + $result = $this->service->getTravelDataFromApi($dateId, $hotelId); + + $this->assertNotNull($result); + $this->assertSame($travel, $result); + } + public function testGetTravelDataFromApiCannotMapDateId(): void { $dateId = 12345; $hotelId = 67890; - $this->travelLoader + $this->travelLookupService ->expects($this->once()) ->method('mapDateIdToProductId') ->with($dateId) @@ -153,175 +217,45 @@ class TravelDataServiceTest extends TestCase $this->assertNull($result); } - public function testExistsLocallyTrueFromSnapshot(): void + public function testExistsLocallyDelegatesToLookupService(): void { $dateId = 12345; $hotelId = 67890; - $this->travelSnapshotService + $this->travelLookupService ->expects($this->once()) - ->method('exists') + ->method('existsLocally') ->with($dateId, $hotelId) ->willReturn(true); - $this->travelLoader - ->expects($this->never()) - ->method('generateFilesMap'); - - $result = $this->service->existsLocally($dateId, $hotelId); - - $this->assertTrue($result); + $this->assertTrue($this->service->existsLocally($dateId, $hotelId)); } - public function testExistsLocallyTrueFromXmlMap(): void + public function testGetAvailableSourcesDelegatesToLookupService(): void { $dateId = 12345; $hotelId = 67890; - $mapping = [ - $dateId => [ - 'id' => $dateId, - 'hotels' => [ - $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'], - ], - ], - ]; + $expected = ['local' => true, 'remote' => true]; - $this->travelSnapshotService + $this->travelLookupService ->expects($this->once()) - ->method('exists') + ->method('getAvailableSources') ->with($dateId, $hotelId) - ->willReturn(false); + ->willReturn($expected); - $this->travelLoader - ->expects($this->once()) - ->method('generateFilesMap') - ->willReturn($mapping); - - $result = $this->service->existsLocally($dateId, $hotelId); - - $this->assertTrue($result); + $this->assertSame($expected, $this->service->getAvailableSources($dateId, $hotelId)); } - public function testExistsLocallyFalseNoTravel(): void + public function testGenerateFilesMapDelegatesToLookupService(): void { - $dateId = 12345; - $hotelId = 67890; - $mapping = []; + $mapping = [12345 => ['id' => 12345, 'hotels' => []]]; - $this->travelSnapshotService - ->expects($this->once()) - ->method('exists') - ->with($dateId, $hotelId) - ->willReturn(false); - - $this->travelLoader + $this->travelLookupService ->expects($this->once()) ->method('generateFilesMap') ->willReturn($mapping); - $result = $this->service->existsLocally($dateId, $hotelId); - - $this->assertFalse($result); - } - - public function testExistsLocallyFalseNoHotel(): void - { - $dateId = 12345; - $hotelId = 67890; - $mapping = [ - $dateId => [ - 'id' => $dateId, - 'hotels' => [], - ], - ]; - - $this->travelSnapshotService - ->expects($this->once()) - ->method('exists') - ->with($dateId, $hotelId) - ->willReturn(false); - - $this->travelLoader - ->expects($this->once()) - ->method('generateFilesMap') - ->willReturn($mapping); - - $result = $this->service->existsLocally($dateId, $hotelId); - - $this->assertFalse($result); - } - - public function testGetAvailableSources(): void - { - $dateId = 12345; - $hotelId = 67890; - $productId = 555; - $mapping = [ - $dateId => [ - 'id' => $dateId, - 'hotels' => [ - $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'], - ], - ], - ]; - - $this->travelSnapshotService - ->expects($this->once()) - ->method('exists') - ->with($dateId, $hotelId) - ->willReturn(false); - - $this->travelLoader - ->expects($this->once()) - ->method('generateFilesMap') - ->willReturn($mapping); - - $this->travelLoader - ->expects($this->once()) - ->method('mapDateIdToProductId') - ->with($dateId) - ->willReturn($productId); - - $result = $this->service->getAvailableSources($dateId, $hotelId); - - $this->assertEquals([ - 'local' => true, - 'remote' => true, - ], $result); - } - - public function testGenerateFilesMapIsMemoizedWithinRequest(): void - { - $dateId = 12345; - $hotelId = 67890; - $mapping = [ - $dateId => [ - 'id' => $dateId, - 'hotels' => [ - $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'], - ], - ], - ]; - - // Both existsLocally() calls go through generateFilesMap(); the underlying - // loader and snapshot service must each be invoked only once. - $this->travelSnapshotService - ->expects($this->exactly(2)) - ->method('exists') - ->willReturn(false); - - $this->travelLoader - ->expects($this->once()) - ->method('generateFilesMap') - ->willReturn($mapping); - - $this->travelSnapshotService - ->expects($this->once()) - ->method('generateMapping') - ->willReturn([]); - - $this->service->existsLocally($dateId, $hotelId); - $this->service->existsLocally($dateId, $hotelId); + $this->assertSame($mapping, $this->service->generateFilesMap()); } public function testGetTravelDataFromLocalPrefersSnapshot(): void @@ -342,53 +276,14 @@ class TravelDataServiceTest extends TestCase ->expects($this->never()) ->method('loadById'); - $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); - - $this->assertSame($travel, $result); - } - - public function testGetTravelDataFromLocalReplacesSnapshotInsurancesWithLoaderData(): void - { - $dateId = 12345; - $hotelId = 67890; - - $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; - - $package = new Insurance(); - $package->id = '20'; - $package->package = true; - $package->containedInsuranceIds = ['10']; - // InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data. - $package->containedInsurances = [$individual]; - - $this->travelSnapshotService + $this->travelEnrichmentService ->expects($this->once()) - ->method('loadTravel') - ->with($dateId, $hotelId) - ->willReturn($travel); - - $this->insuranceLoader - ->expects($this->once()) - ->method('loadAll') - ->willReturn([$individual, $package]); + ->method('patchInsurances') + ->with($travel); $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]); } public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void @@ -411,105 +306,23 @@ class TravelDataServiceTest extends TestCase ->with($dateId, $hotelId) ->willReturn($travel); - $this->pickupLoader + $this->travelEnrichmentService ->expects($this->once()) - ->method('patchPickupsDetails') - ->with($travel); - - $this->hotelLoader - ->expects($this->once()) - ->method('patchHotelDetails') - ->with($travel); + ->method('enrichFromXml') + ->with($travel) + ->willReturn(true); $this->travelSnapshotService ->expects($this->once()) ->method('upsertFromTravel') ->with($travel); - $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); - - $this->assertSame($travel, $result); - } - - public function testGetTravelDataFromLocalAttachesLoaderInsurancesFromXmlFallback(): void - { - $dateId = 12345; - $hotelId = 67890; - - $individual = new Insurance(); - $individual->id = '10'; - $individual->package = false; - - $package = new Insurance(); - $package->id = '20'; - $package->package = true; - $package->containedInsuranceIds = ['10']; - // InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data. - $package->containedInsurances = [$individual]; - - $travel = new Travel(); - $travel->id = $dateId; - $travel->hotelId = $hotelId; - - $this->travelSnapshotService + $this->travelEnrichmentService ->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; - $travel = new Travel(); - $travel->id = $dateId; - $travel->hotelId = $hotelId; - - $this->cache - ->expects($this->never()) - ->method('get'); - - $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') + ->method('patchInsurances') ->with($travel); - $this->hotelLoader - ->expects($this->once()) - ->method('patchHotelDetails') - ->with($travel); - - $result = $this->service->getTravelData($dateId, $hotelId, false); + $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); } @@ -579,6 +392,10 @@ class TravelDataServiceTest extends TestCase ->with($dateId, $hotelId) ->willReturn($travel); + $this->travelEnrichmentService + ->method('enrichFromXml') + ->willReturn(true); + $this->travelSnapshotService ->expects($this->once()) ->method('upsertFromTravel') @@ -594,4 +411,33 @@ class TravelDataServiceTest extends TestCase $this->assertNotNull($result); $this->assertSame($travel, $result); } + + public function testGetTravelDataDelegatesToLoadUncached(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->cache + ->expects($this->never()) + ->method('get'); + + $this->travelSnapshotService + ->expects($this->once()) + ->method('loadTravel') + ->with($dateId, $hotelId) + ->willReturn(null); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $result = $this->service->getTravelData($dateId, $hotelId, false); + + $this->assertSame($travel, $result); + } } diff --git a/tests/Service/TravelEnrichmentServiceTest.php b/tests/Service/TravelEnrichmentServiceTest.php new file mode 100644 index 0000000..fa1573a --- /dev/null +++ b/tests/Service/TravelEnrichmentServiceTest.php @@ -0,0 +1,156 @@ +pickupLoader = $this->createMock(PickupLoader::class); + $this->hotelLoader = $this->createMock(HotelLoader::class); + $this->insuranceLoader = $this->createMock(InsuranceLoader::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new TravelEnrichmentService( + $this->pickupLoader, + $this->hotelLoader, + $this->insuranceLoader, + $this->logger, + ); + } + + // ------------------------------------------------------------------------- + // enrichFromXml + // ------------------------------------------------------------------------- + + public function testEnrichFromXmlReturnsTrueOnSuccess(): void + { + $travel = new Travel(); + $travel->id = 100; + + $this->pickupLoader + ->expects($this->once()) + ->method('patchPickupsDetails') + ->with($travel); + + $this->hotelLoader + ->expects($this->once()) + ->method('patchHotelDetails') + ->with($travel); + + $this->assertTrue($this->service->enrichFromXml($travel)); + } + + public function testEnrichFromXmlReturnsFalseAndLogsWarningOnFailure(): void + { + $travel = new Travel(); + $travel->id = 100; + + $this->pickupLoader + ->method('patchPickupsDetails') + ->willThrowException(new \RuntimeException('XML error')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to enrich travel data', $this->arrayHasKey('travelId')); + + $this->assertFalse($this->service->enrichFromXml($travel)); + } + + // ------------------------------------------------------------------------- + // patchInsurances + // ------------------------------------------------------------------------- + + public function testPatchInsurancesReplacesExistingInsurances(): void + { + $travel = new Travel(); + $travel->id = 100; + + $stale = new Insurance(); + $stale->id = 'stale'; + $travel->insurances = [$stale]; + + $individual = new Insurance(); + $individual->id = '10'; + $individual->package = false; + + $package = new Insurance(); + $package->id = '20'; + $package->package = true; + $package->containedInsuranceIds = ['10']; + // InsuranceParser populates containedInsurances at parse time + $package->containedInsurances = [$individual]; + + $this->insuranceLoader + ->expects($this->once()) + ->method('loadAll') + ->willReturn([$individual, $package]); + + $this->service->patchInsurances($travel); + + $this->assertSame([$individual, $package], $travel->insurances); + } + + public function testPatchInsurancesPreservesContainedInsurancesFromParser(): void + { + $travel = new Travel(); + $travel->id = 100; + + $individual = new Insurance(); + $individual->id = '10'; + $individual->package = false; + + $package = new Insurance(); + $package->id = '20'; + $package->package = true; + $package->containedInsuranceIds = ['10']; + $package->containedInsurances = [$individual]; + + $this->insuranceLoader->method('loadAll')->willReturn([$individual, $package]); + + $this->service->patchInsurances($travel); + + $this->assertCount(1, $package->containedInsurances); + $this->assertSame($individual, $package->containedInsurances[0]); + } + + public function testPatchInsurancesLogsWarningOnFailureAndDoesNotThrow(): void + { + $travel = new Travel(); + $travel->id = 100; + $original = []; + $travel->insurances = $original; + + $this->insuranceLoader + ->method('loadAll') + ->willThrowException(new \RuntimeException('XML parse error')); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to load insurance data', $this->arrayHasKey('travelId')); + + // Must not throw; insurances stay unchanged + $this->service->patchInsurances($travel); + + $this->assertSame($original, $travel->insurances); + } +} diff --git a/tests/Service/TravelLookupServiceTest.php b/tests/Service/TravelLookupServiceTest.php new file mode 100644 index 0000000..0df37f4 --- /dev/null +++ b/tests/Service/TravelLookupServiceTest.php @@ -0,0 +1,297 @@ +travelLoader = $this->createMock(TravelLoader::class); + $this->hotelLoader = $this->createMock(HotelLoader::class); + $this->travelSnapshotService = $this->createMock(TravelSnapshotService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new TravelLookupService( + $this->travelLoader, + $this->hotelLoader, + $this->travelSnapshotService, + $this->logger, + ); + } + + // ------------------------------------------------------------------------- + // generateFilesMap + // ------------------------------------------------------------------------- + + public function testGenerateFilesMapMergesXmlAndSnapshotMappings(): void + { + $dateId = 100; + $hotelId = 200; + $snapshotOnlyDateId = 300; + + $xmlMapping = [ + $dateId => ['id' => $dateId, 'code' => 'ABC', 'hotels' => [ + $hotelId => ['id' => $hotelId], + ]], + ]; + $snapshotMapping = [ + $dateId => ['id' => $dateId, 'code' => 'ABC', 'hotels' => [ + 999 => ['id' => 999], // extra hotel only in snapshot + ]], + $snapshotOnlyDateId => ['id' => $snapshotOnlyDateId, 'code' => 'XYZ', 'hotels' => []], + ]; + + $this->travelLoader->method('generateFilesMap')->willReturn($xmlMapping); + $this->travelSnapshotService->method('generateMapping')->willReturn($snapshotMapping); + + $result = $this->service->generateFilesMap(); + + // XML entry is preserved + $this->assertArrayHasKey($dateId, $result); + // Snapshot-only date is merged in + $this->assertArrayHasKey($snapshotOnlyDateId, $result); + // Extra hotel from snapshot is added to the XML date entry + $this->assertArrayHasKey(999, $result[$dateId]['hotels']); + // Original XML hotel is still present + $this->assertArrayHasKey($hotelId, $result[$dateId]['hotels']); + } + + public function testGenerateFilesMapIsMemoizedWithinRequest(): void + { + $mapping = [100 => ['id' => 100, 'code' => 'A', 'hotels' => []]]; + + $this->travelLoader + ->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($mapping); + + $this->travelSnapshotService + ->expects($this->once()) + ->method('generateMapping') + ->willReturn([]); + + // Two calls — loaders invoked only once + $this->service->generateFilesMap(); + $result = $this->service->generateFilesMap(); + + $this->assertSame($mapping, $result); + } + + public function testGenerateFilesMapFallsBackToSnapshotsOnXmlFailure(): void + { + $snapshotMapping = [100 => ['id' => 100, 'code' => 'A', 'hotels' => []]]; + + $this->travelLoader + ->method('generateFilesMap') + ->willThrowException(new \RuntimeException('Filesystem error')); + + $this->travelSnapshotService + ->method('generateMapping') + ->willReturn($snapshotMapping); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to generate XML files mapping, fallback to snapshots only', $this->arrayHasKey('error')); + + $result = $this->service->generateFilesMap(); + + $this->assertSame($snapshotMapping, $result); + } + + // ------------------------------------------------------------------------- + // mapDateCodeToId + // ------------------------------------------------------------------------- + + public function testMapDateCodeToIdReturnsMatchingId(): void + { + $this->travelLoader->method('generateFilesMap')->willReturn([ + 42 => ['id' => 42, 'code' => 'WI25', 'hotels' => []], + ]); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $result = $this->service->mapDateCodeToId('WI25'); + + $this->assertSame(42, $result); + } + + public function testMapDateCodeToIdReturnsNullWhenNotFound(): void + { + $this->travelLoader->method('generateFilesMap')->willReturn([]); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $result = $this->service->mapDateCodeToId('UNKNOWN'); + + $this->assertNull($result); + } + + // ------------------------------------------------------------------------- + // mapHotelCodeToId + // ------------------------------------------------------------------------- + + public function testMapHotelCodeToIdDelegatesToHotelLoader(): void + { + $this->hotelLoader + ->expects($this->once()) + ->method('mapCodeToId') + ->with('HTL001') + ->willReturn(77); + + $result = $this->service->mapHotelCodeToId('HTL001'); + + $this->assertSame(77, $result); + } + + public function testMapHotelCodeToIdReturnsNullOnException(): void + { + $this->hotelLoader + ->method('mapCodeToId') + ->willThrowException(new \RuntimeException('Not found')); + + $result = $this->service->mapHotelCodeToId('MISSING'); + + $this->assertNull($result); + } + + // ------------------------------------------------------------------------- + // mapDateIdToProductId + // ------------------------------------------------------------------------- + + public function testMapDateIdToProductIdPrefersSnapshot(): void + { + $this->travelSnapshotService + ->expects($this->once()) + ->method('findProductIdByDateId') + ->with(100) + ->willReturn(999); + + $this->travelLoader + ->expects($this->never()) + ->method('mapDateIdToProductId'); + + $result = $this->service->mapDateIdToProductId(100); + + $this->assertSame(999, $result); + } + + public function testMapDateIdToProductIdFallsBackToLoaderWhenSnapshotReturnsNull(): void + { + $this->travelSnapshotService + ->expects($this->once()) + ->method('findProductIdByDateId') + ->with(100) + ->willReturn(null); + + $this->travelLoader + ->expects($this->once()) + ->method('mapDateIdToProductId') + ->with(100) + ->willReturn(42); + + $result = $this->service->mapDateIdToProductId(100); + + $this->assertSame(42, $result); + } + + // ------------------------------------------------------------------------- + // existsLocally + // ------------------------------------------------------------------------- + + 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'); + + $this->assertTrue($this->service->existsLocally($dateId, $hotelId)); + } + + public function testExistsLocallyTrueFromXmlMap(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = [ + $dateId => [ + 'id' => $dateId, + 'hotels' => [$hotelId => ['id' => $hotelId]], + ], + ]; + + $this->travelSnapshotService->method('exists')->willReturn(false); + $this->travelLoader->method('generateFilesMap')->willReturn($mapping); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $this->assertTrue($this->service->existsLocally($dateId, $hotelId)); + } + + public function testExistsLocallyFalseNoTravel(): void + { + $this->travelSnapshotService->method('exists')->willReturn(false); + $this->travelLoader->method('generateFilesMap')->willReturn([]); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $this->assertFalse($this->service->existsLocally(12345, 67890)); + } + + public function testExistsLocallyFalseNoHotel(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = [ + $dateId => ['id' => $dateId, 'hotels' => []], + ]; + + $this->travelSnapshotService->method('exists')->willReturn(false); + $this->travelLoader->method('generateFilesMap')->willReturn($mapping); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $this->assertFalse($this->service->existsLocally($dateId, $hotelId)); + } + + // ------------------------------------------------------------------------- + // getAvailableSources + // ------------------------------------------------------------------------- + + public function testGetAvailableSourcesReturnsBothFlags(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = [ + $dateId => ['id' => $dateId, 'hotels' => [$hotelId => []]], + ]; + + $this->travelSnapshotService->method('exists')->willReturn(false); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + $this->travelSnapshotService->method('findProductIdByDateId')->willReturn(null); + $this->travelLoader->method('generateFilesMap')->willReturn($mapping); + $this->travelLoader->method('mapDateIdToProductId')->with($dateId)->willReturn(555); + + $result = $this->service->getAvailableSources($dateId, $hotelId); + + $this->assertEquals(['local' => true, 'remote' => true], $result); + } +}