feat: simplify booking pricing flow

This commit is contained in:
Björn Fromme
2026-04-11 18:28:32 +02:00
parent a0cbd5a23f
commit 346256f895
10 changed files with 1561 additions and 1267 deletions
+365 -7
View File
@@ -4,20 +4,25 @@ declare(strict_types=1);
namespace App\Service; 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\BookingDto;
use App\Form\Model\ParticipantDto;
/** /**
* Facade for booking pricing calculations. * Facade for booking pricing calculations.
* *
* Provides comprehensive pricing calculations for the booking system by * 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. * Returns structured pricing data for display in forms and summaries.
*/ */
class BookingPriceCalculatorService class BookingPriceCalculatorService
{ {
public function __construct( public function __construct(
private readonly RoomPricingCalculator $roomPricingCalculator, private readonly RoomPricingCalculator $roomPricingCalculator,
private readonly ServicePricingCalculator $servicePricingCalculator, private readonly ParticipantEligibilityService $participantEligibilityService,
private readonly InsuranceService $insuranceService,
private readonly ParticipantPricingCalculator $participantPricingCalculator, private readonly ParticipantPricingCalculator $participantPricingCalculator,
) { ) {
} }
@@ -35,7 +40,7 @@ class BookingPriceCalculatorService
): array ): array
{ {
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode); $roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
$servicePricing = $this->servicePricingCalculator->calculateServicePricing($bookingDto); $servicePricing = $this->calculateServicePricing($bookingDto);
$grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode); $grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode);
$result = [ $result = [
@@ -82,11 +87,31 @@ class BookingPriceCalculatorService
* *
* @param BookingDto $bookingDto The booking data containing participants and their service selections * @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<int, array<string, mixed>> Array of service groups with each group containing services of the same subtype
*/ */
public function calculateServicePricing(BookingDto $bookingDto): array 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 ): float
{ {
$roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode); $roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
$serviceTotal = $this->servicePricingCalculator->calculateServiceTotal($bookingDto); $serviceTotal = $this->calculateServiceTotal($bookingDto);
return $roomTotal + $serviceTotal; return $roomTotal + $serviceTotal;
} }
@@ -123,7 +148,14 @@ class BookingPriceCalculatorService
*/ */
public function calculateServiceTotal(BookingDto $bookingDto): float 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); 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<string, array<string, mixed>> 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<string, array<string, mixed>> $serviceAggregation
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $serviceData
*
* @return array<string, mixed>
*/
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. * Calculates the total price for an individual participant.
* *
@@ -45,9 +45,6 @@ class BookingSummaryDataService
// Calculate individual prices for all participants // Calculate individual prices for all participants
$participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto); $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
// Calculate total price
$totalPrice = array_sum($participantPrices);
// Get room assignment counts // Get room assignment counts
$roomCounts = []; $roomCounts = [];
foreach ($bookingDto->participants as $participant) { foreach ($bookingDto->participants as $participant) {
-447
View File
@@ -1,447 +0,0 @@
<?php
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;
/**
* Calculates pricing for services across all participants in a booking.
*
* Handles aggregation of service selections, transportation costs, and
* insurance pricing with bulk insurance support. Groups services by
* subtype for display with separate discount entries.
*/
class ServicePricingCalculator
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService,
private readonly InsuranceService $insuranceService,
private readonly ParticipantPricingCalculator $participantPricingCalculator,
) {
}
/**
* Calculates pricing for all selected services across all participants, grouped by subtype.
*
* Only includes services from eligible participants (those with available skipasses for their age).
*
* @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
*/
public function calculateServicePricing(BookingDto $bookingDto): array
{
$participants = $bookingDto->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;
}
}
+284 -472
View File
@@ -10,9 +10,6 @@ use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel; 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\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException; 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 * 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 * prefer snapshots for performance and refresh those snapshots from XML during sync. It handles
* caching, error recovery, and data enrichment for both data sources. * 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 class TravelDataService
{ {
@@ -37,23 +37,23 @@ class TravelDataService
private const int AVAILABILITY_CACHE_TTL = 600; private const int AVAILABILITY_CACHE_TTL = 600;
private const int MUTABILITY_CACHE_TTL = 300; private const int MUTABILITY_CACHE_TTL = 300;
/** @var array<int, array<string, mixed>>|null */
private ?array $filesMapCache = null;
public function __construct( public function __construct(
private readonly TravelLoader $travelLoader, private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly TravelSnapshotService $travelSnapshotService, private readonly TravelSnapshotService $travelSnapshotService,
private readonly TravelLookupService $travelLookupService,
private readonly TravelEnrichmentService $travelEnrichmentService,
private readonly bool $preferRemote = false, private readonly bool $preferRemote = false,
private readonly bool $enableFallback = true, private readonly bool $enableFallback = true,
) { ) {
} }
// -------------------------------------------------------------------------
// Travel loading
// -------------------------------------------------------------------------
/** /**
* Retrieve travel data with automatic source selection and fallback. * Retrieve travel data with automatic source selection and fallback.
* *
@@ -78,19 +78,21 @@ class TravelDataService
* Retrieve travel data specifically from XML files. * Retrieve travel data specifically from XML files.
* *
* Loads travel data from local XML files with full data enrichment including * 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 $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data * @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 public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
{ {
try { try {
$travel = $this->travelLoader->loadById($dateId, $hotelId); $travel = $this->travelLoader->loadById($dateId, $hotelId);
$this->enrichTravelData($travel);
} catch (TravelNotFoundException $e) { } catch (TravelNotFoundException $e) {
$this->logger->debug('Travel not found in XML', [ $this->logger->debug('Travel not found in XML', [
'dateId' => $dateId, 'dateId' => $dateId,
@@ -115,16 +117,20 @@ class TravelDataService
return null; return null;
} }
// Persist snapshot separately so a DB/serializer failure does not discard a // Only persist a fully-enriched snapshot. A partial enrichment (failed pickup/hotel
// successfully loaded travel. // loaders) must not overwrite an existing good snapshot with incomplete data.
try { $enriched = $this->travelEnrichmentService->enrichFromXml($travel);
$this->travelSnapshotService->upsertFromTravel($travel);
} catch (\Throwable $e) { if ($enriched) {
$this->logger->warning('Failed to persist travel snapshot after XML load', [ try {
'dateId' => $dateId, $this->travelSnapshotService->upsertFromTravel($travel);
'hotelId' => $hotelId, } catch (\Throwable $e) {
'error' => $e->getMessage(), $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', [ $this->logger->debug('Travel data loaded from XML', [
@@ -140,7 +146,8 @@ class TravelDataService
* Retrieve travel data from locally persisted sources. * Retrieve travel data from locally persisted sources.
* *
* Runtime local reads prefer snapshots for performance and only fall back to XML * 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 public function getTravelDataFromLocal(int $dateId, ?int $hotelId = null): ?Travel
{ {
@@ -148,18 +155,73 @@ class TravelDataService
?? $this->getTravelDataFromXml($dateId, $hotelId); ?? $this->getTravelDataFromXml($dateId, $hotelId);
if (null !== $travel) { if (null !== $travel) {
$this->patchInsuranceData($travel); $this->travelEnrichmentService->patchInsurances($travel);
} }
return $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 { try {
$travel = $this->travelSnapshotService->loadTravel($dateId, $hotelId); $productId = $this->travelLookupService->mapDateIdToProductId($dateId);
} catch (\Throwable $e) { if (null === $productId) {
$this->logger->warning('Snapshot lookup failed, falling back to XML', [ $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, 'dateId' => $dateId,
'hotelId' => $hotelId, 'hotelId' => $hotelId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@@ -167,18 +229,6 @@ class TravelDataService
return null; 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]; 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 * @param int $dateId The travel date ID for API call
* product IDs rather than date IDs, so mapping is performed internally. * @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 * @return BaseData|null The mutability data or null if not available or error occurred
* @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 public function getMutabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?BaseData
{ {
try { if ($cached) {
// Map dateId to productId for API call return $this->fetchCached(
$productId = $this->mapDateIdToProductId($dateId); sprintf('mutability_%d', $dateId),
if (null === $productId) { self::MUTABILITY_CACHE_TTL,
$this->logger->debug('Cannot map dateId to productId for API call', [ fn () => $this->fetchMutabilityData($dateId),
'dateId' => $dateId, $forceRefresh,
'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;
} }
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 * @param Travel $travel The travel object to update
* local sources without loading the full travel object. * @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 $dateId The travel date ID to check
* @param int|null $hotelId Optional hotel ID for specific hotel data * @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 public function existsLocally(int $dateId, ?int $hotelId = null): bool
{ {
if (true === $this->travelSnapshotService->exists($dateId, $hotelId)) { return $this->travelLookupService->existsLocally($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;
} }
/** /**
* Get information about available data sources for a travel. * 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 $dateId The travel date ID to check
* @param int|null $hotelId Optional hotel ID for specific hotel data * @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 public function getAvailableSources(int $dateId, ?int $hotelId = null): array
{ {
return [ return $this->travelLookupService->getAvailableSources($dateId, $hotelId);
static::SOURCE_LOCAL => $this->existsLocally($dateId, $hotelId),
static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId),
];
} }
/** /**
* Load travel data directly without caching. * Map date code to date ID.
* *
* Internal method that handles the actual loading logic with fallback support. * @param string $dateCode The date code to map
* Tries the preferred source first, then falls back to the alternative if enabled.
* *
* @param int $dateId The travel date ID to retrieve * @return int|null The corresponding date ID or null if not found
* @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
*/ */
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<int, array<string, mixed>> 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 private function loadTravelDataUncached(int $dateId, ?int $hotelId = null, bool $preferRemote = false): ?Travel
{ {
$primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL; $primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL;
$fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE; $fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE;
// Try primary source first
$travel = $this->loadFromSource($dateId, $hotelId, $primarySource); $travel = $this->loadFromSource($dateId, $hotelId, $primarySource);
if (null !== $travel) { if (null !== $travel) {
return $travel; return $travel;
} }
// Try fallback source if enabled
if (true === $this->enableFallback) { if (true === $this->enableFallback) {
$this->logger->debug('Fallback to alternative source', [ $this->logger->debug('Fallback to alternative source', [
'dateId' => $dateId, 'dateId' => $dateId,
@@ -380,17 +512,6 @@ class TravelDataService
return $travel; 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 private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel
{ {
return match ($source) { 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<int, array<string, mixed>> 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 private function fetchMutabilityData(int $dateId): ?BaseData
{ {
try { 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 private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse
{ {
try { 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 * On cache error the value is computed directly so callers are never blocked.
* services, and the allowed booking status, based on the provided * Pass $forceRefresh=true to delete the cached entry before fetching.
* availability data.
* *
* @param Travel $travel The travel object to update * @template T
* @param ServiceAvailabilityResponse $availabilities The availability data for services *
* @param callable(): T $fetcher
*
* @return T|null
*/ */
public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void private function fetchCached(string $cacheKey, int $ttl, callable $fetcher, bool $forceRefresh): mixed
{
$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
{ {
try { try {
$this->pickupLoader->patchPickupsDetails($travel); if ($forceRefresh) {
$this->hotelLoader->patchHotelDetails($travel); $this->cache->delete($cacheKey);
} catch (\Exception $e) { }
$this->logger->warning('Failed to enrich travel data', [
'travelId' => $travel->id, 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(), '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(),
]);
}
}
} }
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use Psr\Log\LoggerInterface;
/**
* Enriches Travel objects with supplementary data from XML loaders.
*
* Handles two distinct enrichment passes:
* - XML enrichment (pickups + hotel details) for travels freshly loaded from XML
* - Insurance patching applied to every local travel, replacing any stale snapshot data
* with freshly parsed, fully-hydrated Insurance objects from the XML loader
*/
class TravelEnrichmentService
{
public function __construct(
private readonly PickupLoader $pickupLoader,
private readonly HotelLoader $hotelLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly LoggerInterface $logger,
) {
}
/**
* Add pickup and hotel details to a travel freshly loaded from XML.
*
* Returns true when both loaders succeed. Returns false on any loader failure
* (the failure is logged as a warning) so callers can decide whether to persist
* the partially-enriched travel as a snapshot.
*
* @param Travel $travel The travel object to enrich in-place
*
* @return bool True if enrichment completed fully; false if any loader failed
*/
public function enrichFromXml(Travel $travel): bool
{
try {
$this->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(),
]);
}
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use Psr\Log\LoggerInterface;
/**
* Provides lightweight lookup and existence checks for travel data across XML and snapshot sources.
*
* Consolidates code/ID mapping, dateId→productId resolution, and the combined XML+snapshot
* files map. All operations are read-only and never load full Travel objects.
*/
class TravelLookupService
{
/** @var array<int, array<string, mixed>>|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<int, array<string, mixed>>
*/
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),
];
}
}
@@ -16,38 +16,24 @@ use App\Service\InsuranceService;
use App\Service\ParticipantEligibilityService; use App\Service\ParticipantEligibilityService;
use App\Service\ParticipantPricingCalculator; use App\Service\ParticipantPricingCalculator;
use App\Service\RoomPricingCalculator; use App\Service\RoomPricingCalculator;
use App\Service\ServicePricingCalculator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class BookingPriceCalculatorServiceTest extends TestCase class BookingPriceCalculatorServiceTest extends TestCase
{ {
private BookingPriceCalculatorService $service; private BookingPriceCalculatorService $service;
private ParticipantEligibilityService $participantEligibilityService; private ParticipantEligibilityService $participantEligibilityService;
private RoomPricingCalculator $roomPricingCalculator;
private ParticipantPricingCalculator $participantPricingCalculator;
protected function setUp(): void protected function setUp(): void
{ {
$this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); $this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
$this->participantEligibilityService->method('isParticipantEligible')->willReturn(true); $this->participantEligibilityService->method('isParticipantEligible')->willReturn(true);
// Build the calculator chain $this->roomPricingCalculator = new RoomPricingCalculator();
$roomPricingCalculator = new RoomPricingCalculator(); $this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator);
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
// Create InsuranceService with a mock price calculator (to avoid circular reference in tests) $this->service = $this->createService($this->participantEligibilityService);
$mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$insuranceService = new InsuranceService($mockPriceCalculator);
$servicePricingCalculator = new ServicePricingCalculator(
$this->participantEligibilityService,
$insuranceService,
$participantPricingCalculator
);
$this->service = new BookingPriceCalculatorService(
$roomPricingCalculator,
$servicePricingCalculator,
$participantPricingCalculator
);
} }
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
@@ -613,24 +599,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$participantEligibilityService->method('isParticipantEligible') $participantEligibilityService->method('isParticipantEligible')
->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible ->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible
// Build the calculator chain with the custom eligibility service $this->service = $this->createService($participantEligibilityService);
$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
);
$result = $this->service->calculateServicePricing($bookingDto); $result = $this->service->calculateServicePricing($bookingDto);
@@ -725,4 +694,14 @@ class BookingPriceCalculatorServiceTest extends TestCase
return $participant; return $participant;
} }
private function createService(ParticipantEligibilityService $participantEligibilityService): BookingPriceCalculatorService
{
return new BookingPriceCalculatorService(
$this->roomPricingCalculator,
$participantEligibilityService,
new InsuranceService(),
$this->participantPricingCalculator,
);
}
} }
+147 -301
View File
@@ -5,14 +5,12 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel; 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\BusProNet\XmlLoader\TravelLoader;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Service\TravelDataService; use App\Service\TravelDataService;
use App\Service\TravelEnrichmentService;
use App\Service\TravelLookupService;
use App\Service\TravelSnapshotService; use App\Service\TravelSnapshotService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -22,34 +20,31 @@ class TravelDataServiceTest extends TestCase
{ {
private TravelDataService $service; private TravelDataService $service;
private TravelLoader $travelLoader; private TravelLoader $travelLoader;
private HotelLoader $hotelLoader;
private PickupLoader $pickupLoader;
private InsuranceLoader $insuranceLoader;
private ApiClient $apiClient; private ApiClient $apiClient;
private CacheInterface $cache; private CacheInterface $cache;
private LoggerInterface $logger; private LoggerInterface $logger;
private TravelSnapshotService $travelSnapshotService; private TravelSnapshotService $travelSnapshotService;
private TravelLookupService $travelLookupService;
private TravelEnrichmentService $travelEnrichmentService;
protected function setUp(): void protected function setUp(): void
{ {
$this->travelLoader = $this->createMock(TravelLoader::class); $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->apiClient = $this->createMock(ApiClient::class);
$this->cache = $this->createMock(CacheInterface::class); $this->cache = $this->createMock(CacheInterface::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->travelSnapshotService = $this->createMock(TravelSnapshotService::class); $this->travelSnapshotService = $this->createMock(TravelSnapshotService::class);
$this->travelLookupService = $this->createMock(TravelLookupService::class);
$this->travelEnrichmentService = $this->createMock(TravelEnrichmentService::class);
$this->service = new TravelDataService( $this->service = new TravelDataService(
$this->travelLoader, $this->travelLoader,
$this->hotelLoader,
$this->pickupLoader,
$this->insuranceLoader,
$this->apiClient, $this->apiClient,
$this->cache, $this->cache,
$this->logger, $this->logger,
$this->travelSnapshotService, $this->travelSnapshotService,
$this->travelLookupService,
$this->travelEnrichmentService,
false, // preferRemote false, // preferRemote
true, // enableFallback true, // enableFallback
); );
@@ -69,15 +64,45 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willReturn($travel); ->willReturn($travel);
$this->pickupLoader $this->travelEnrichmentService
->expects($this->once()) ->expects($this->once())
->method('patchPickupsDetails') ->method('enrichFromXml')
->with($travel)
->willReturn(true);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->with($travel); ->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()) ->expects($this->once())
->method('patchHotelDetails') ->method('loadById')
->with($travel); ->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); $result = $this->service->getTravelDataFromXml($dateId, $hotelId);
@@ -95,13 +120,9 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willThrowException(new TravelNotFoundException($dateId)); ->willThrowException(new TravelNotFoundException($dateId));
$this->pickupLoader $this->travelEnrichmentService
->expects($this->never()) ->expects($this->never())
->method('patchPickupsDetails'); ->method('enrichFromXml');
$this->hotelLoader
->expects($this->never())
->method('patchHotelDetails');
$this->expectException(TravelNotFoundException::class); $this->expectException(TravelNotFoundException::class);
$this->service->getTravelDataFromXml($dateId, $hotelId); $this->service->getTravelDataFromXml($dateId, $hotelId);
@@ -116,7 +137,7 @@ class TravelDataServiceTest extends TestCase
$travel->id = $dateId; $travel->id = $dateId;
$travel->hotelId = $hotelId; $travel->hotelId = $hotelId;
$this->travelLoader $this->travelLookupService
->expects($this->once()) ->expects($this->once())
->method('mapDateIdToProductId') ->method('mapDateIdToProductId')
->with($dateId) ->with($dateId)
@@ -128,17 +149,60 @@ class TravelDataServiceTest extends TestCase
->with($productId, $hotelId) ->with($productId, $hotelId)
->willReturn($travel); ->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); $result = $this->service->getTravelDataFromApi($dateId, $hotelId);
$this->assertSame($travel, $result); $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 public function testGetTravelDataFromApiCannotMapDateId(): void
{ {
$dateId = 12345; $dateId = 12345;
$hotelId = 67890; $hotelId = 67890;
$this->travelLoader $this->travelLookupService
->expects($this->once()) ->expects($this->once())
->method('mapDateIdToProductId') ->method('mapDateIdToProductId')
->with($dateId) ->with($dateId)
@@ -153,175 +217,45 @@ class TravelDataServiceTest extends TestCase
$this->assertNull($result); $this->assertNull($result);
} }
public function testExistsLocallyTrueFromSnapshot(): void public function testExistsLocallyDelegatesToLookupService(): void
{ {
$dateId = 12345; $dateId = 12345;
$hotelId = 67890; $hotelId = 67890;
$this->travelSnapshotService $this->travelLookupService
->expects($this->once()) ->expects($this->once())
->method('exists') ->method('existsLocally')
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willReturn(true); ->willReturn(true);
$this->travelLoader $this->assertTrue($this->service->existsLocally($dateId, $hotelId));
->expects($this->never())
->method('generateFilesMap');
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
} }
public function testExistsLocallyTrueFromXmlMap(): void public function testGetAvailableSourcesDelegatesToLookupService(): void
{ {
$dateId = 12345; $dateId = 12345;
$hotelId = 67890; $hotelId = 67890;
$mapping = [ $expected = ['local' => true, 'remote' => true];
$dateId => [
'id' => $dateId,
'hotels' => [
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
],
],
];
$this->travelSnapshotService $this->travelLookupService
->expects($this->once()) ->expects($this->once())
->method('exists') ->method('getAvailableSources')
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willReturn(false); ->willReturn($expected);
$this->travelLoader $this->assertSame($expected, $this->service->getAvailableSources($dateId, $hotelId));
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
} }
public function testExistsLocallyFalseNoTravel(): void public function testGenerateFilesMapDelegatesToLookupService(): void
{ {
$dateId = 12345; $mapping = [12345 => ['id' => 12345, 'hotels' => []]];
$hotelId = 67890;
$mapping = [];
$this->travelSnapshotService $this->travelLookupService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once()) ->expects($this->once())
->method('generateFilesMap') ->method('generateFilesMap')
->willReturn($mapping); ->willReturn($mapping);
$result = $this->service->existsLocally($dateId, $hotelId); $this->assertSame($mapping, $this->service->generateFilesMap());
$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);
} }
public function testGetTravelDataFromLocalPrefersSnapshot(): void public function testGetTravelDataFromLocalPrefersSnapshot(): void
@@ -342,53 +276,14 @@ class TravelDataServiceTest extends TestCase
->expects($this->never()) ->expects($this->never())
->method('loadById'); ->method('loadById');
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->travelEnrichmentService
$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
->expects($this->once()) ->expects($this->once())
->method('loadTravel') ->method('patchInsurances')
->with($dateId, $hotelId) ->with($travel);
->willReturn($travel);
$this->insuranceLoader
->expects($this->once())
->method('loadAll')
->willReturn([$individual, $package]);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result); $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 public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void
@@ -411,105 +306,23 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willReturn($travel); ->willReturn($travel);
$this->pickupLoader $this->travelEnrichmentService
->expects($this->once()) ->expects($this->once())
->method('patchPickupsDetails') ->method('enrichFromXml')
->with($travel); ->with($travel)
->willReturn(true);
$this->hotelLoader
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
$this->travelSnapshotService $this->travelSnapshotService
->expects($this->once()) ->expects($this->once())
->method('upsertFromTravel') ->method('upsertFromTravel')
->with($travel); ->with($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->travelEnrichmentService
$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
->expects($this->once()) ->expects($this->once())
->method('loadTravel') ->method('patchInsurances')
->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')
->with($travel); ->with($travel);
$this->hotelLoader $result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
$result = $this->service->getTravelData($dateId, $hotelId, false);
$this->assertSame($travel, $result); $this->assertSame($travel, $result);
} }
@@ -579,6 +392,10 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId) ->with($dateId, $hotelId)
->willReturn($travel); ->willReturn($travel);
$this->travelEnrichmentService
->method('enrichFromXml')
->willReturn(true);
$this->travelSnapshotService $this->travelSnapshotService
->expects($this->once()) ->expects($this->once())
->method('upsertFromTravel') ->method('upsertFromTravel')
@@ -594,4 +411,33 @@ class TravelDataServiceTest extends TestCase
$this->assertNotNull($result); $this->assertNotNull($result);
$this->assertSame($travel, $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);
}
} }
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
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\Service\TravelEnrichmentService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class TravelEnrichmentServiceTest extends TestCase
{
private TravelEnrichmentService $service;
private PickupLoader $pickupLoader;
private HotelLoader $hotelLoader;
private InsuranceLoader $insuranceLoader;
private LoggerInterface $logger;
protected function setUp(): void
{
$this->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);
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\TravelLookupService;
use App\Service\TravelSnapshotService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class TravelLookupServiceTest extends TestCase
{
private TravelLookupService $service;
private TravelLoader $travelLoader;
private HotelLoader $hotelLoader;
private TravelSnapshotService $travelSnapshotService;
private LoggerInterface $logger;
protected function setUp(): void
{
$this->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);
}
}