feat: simplify booking pricing flow
This commit is contained in:
@@ -4,20 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Facade for booking pricing calculations.
|
||||
*
|
||||
* Provides comprehensive pricing calculations for the booking system by
|
||||
* coordinating specialized calculators for rooms, services, and participants.
|
||||
* coordinating room and participant calculations and handling service aggregation directly.
|
||||
* Returns structured pricing data for display in forms and summaries.
|
||||
*/
|
||||
class BookingPriceCalculatorService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
private readonly ServicePricingCalculator $servicePricingCalculator,
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||
private readonly InsuranceService $insuranceService,
|
||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||
) {
|
||||
}
|
||||
@@ -35,7 +40,7 @@ class BookingPriceCalculatorService
|
||||
): array
|
||||
{
|
||||
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
||||
$servicePricing = $this->servicePricingCalculator->calculateServicePricing($bookingDto);
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
$grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode);
|
||||
|
||||
$result = [
|
||||
@@ -82,11 +87,31 @@ class BookingPriceCalculatorService
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing participants and their service selections
|
||||
*
|
||||
* @return array Array of service groups with each group containing services of the same subtype
|
||||
* @return array<int, array<string, mixed>> Array of service groups with each group containing services of the same subtype
|
||||
*/
|
||||
public function calculateServicePricing(BookingDto $bookingDto): array
|
||||
{
|
||||
return $this->servicePricingCalculator->calculateServicePricing($bookingDto);
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
if ([] === $participants) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serviceAggregation = [];
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
||||
}
|
||||
|
||||
foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) {
|
||||
$serviceAggregation[$key] = $transportationItem;
|
||||
}
|
||||
|
||||
return $this->groupServicesBySubtype($serviceAggregation);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +127,7 @@ class BookingPriceCalculatorService
|
||||
): float
|
||||
{
|
||||
$roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
||||
$serviceTotal = $this->servicePricingCalculator->calculateServiceTotal($bookingDto);
|
||||
$serviceTotal = $this->calculateServiceTotal($bookingDto);
|
||||
|
||||
return $roomTotal + $serviceTotal;
|
||||
}
|
||||
@@ -123,7 +148,14 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateServiceTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
return $this->servicePricingCalculator->calculateServiceTotal($bookingDto);
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($servicePricing as $group) {
|
||||
$total += $group['groupTotal'];
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +207,332 @@ class BookingPriceCalculatorService
|
||||
return '€'.$this->formatPrice($price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all transportation-related services and pricing into separate line items.
|
||||
*
|
||||
* Creates separate entries for:
|
||||
* - Befoerderung: Sum of all positive pickup prices and base transportation costs
|
||||
* - Befoerderung - Rabatt: Sum of all negative transportation prices (discounts)
|
||||
* - Parkplatz: Sum of all parking service prices
|
||||
*
|
||||
* Only includes transportation costs from eligible participants.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing participants
|
||||
*
|
||||
* @return array<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.
|
||||
*
|
||||
|
||||
@@ -45,9 +45,6 @@ class BookingSummaryDataService
|
||||
// Calculate individual prices for all participants
|
||||
$participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
|
||||
// Calculate total price
|
||||
$totalPrice = array_sum($participantPrices);
|
||||
|
||||
// Get room assignment counts
|
||||
$roomCounts = [];
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+265
-453
@@ -10,9 +10,6 @@ use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\InsuranceLoader;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\Exception\HotelNotFoundException;
|
||||
use App\Exception\HotelNotInTravelException;
|
||||
@@ -29,6 +26,9 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
* supporting automatic fallback between local persisted data and remote API calls. Local reads
|
||||
* prefer snapshots for performance and refresh those snapshots from XML during sync. It handles
|
||||
* caching, error recovery, and data enrichment for both data sources.
|
||||
*
|
||||
* Mapping/lookup operations are delegated to TravelLookupService.
|
||||
* Travel enrichment (XML details, insurances) is delegated to TravelEnrichmentService.
|
||||
*/
|
||||
class TravelDataService
|
||||
{
|
||||
@@ -37,23 +37,23 @@ class TravelDataService
|
||||
private const int AVAILABILITY_CACHE_TTL = 600;
|
||||
private const int MUTABILITY_CACHE_TTL = 300;
|
||||
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $filesMapCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly TravelLoader $travelLoader,
|
||||
private readonly HotelLoader $hotelLoader,
|
||||
private readonly PickupLoader $pickupLoader,
|
||||
private readonly InsuranceLoader $insuranceLoader,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly TravelSnapshotService $travelSnapshotService,
|
||||
private readonly TravelLookupService $travelLookupService,
|
||||
private readonly TravelEnrichmentService $travelEnrichmentService,
|
||||
private readonly bool $preferRemote = false,
|
||||
private readonly bool $enableFallback = true,
|
||||
) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Travel loading
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve travel data with automatic source selection and fallback.
|
||||
*
|
||||
@@ -78,19 +78,21 @@ class TravelDataService
|
||||
* Retrieve travel data specifically from XML files.
|
||||
*
|
||||
* Loads travel data from local XML files with full data enrichment including
|
||||
* hotel details and pickup information.
|
||||
* hotel details and pickup information, then persists a snapshot.
|
||||
*
|
||||
* @param int $dateId The travel date ID to retrieve
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
*
|
||||
* @return Travel|null The travel data or null if not found in XML
|
||||
* @return Travel|null The travel data or null on unexpected failure
|
||||
*
|
||||
* @throws TravelNotFoundException When the travel date is not found
|
||||
* @throws HotelNotFoundException When the requested hotel is not found
|
||||
* @throws HotelNotInTravelException When the hotel does not belong to this travel
|
||||
*/
|
||||
public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
|
||||
{
|
||||
try {
|
||||
$travel = $this->travelLoader->loadById($dateId, $hotelId);
|
||||
|
||||
$this->enrichTravelData($travel);
|
||||
} catch (TravelNotFoundException $e) {
|
||||
$this->logger->debug('Travel not found in XML', [
|
||||
'dateId' => $dateId,
|
||||
@@ -115,8 +117,11 @@ class TravelDataService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Persist snapshot separately so a DB/serializer failure does not discard a
|
||||
// successfully loaded travel.
|
||||
// Only persist a fully-enriched snapshot. A partial enrichment (failed pickup/hotel
|
||||
// loaders) must not overwrite an existing good snapshot with incomplete data.
|
||||
$enriched = $this->travelEnrichmentService->enrichFromXml($travel);
|
||||
|
||||
if ($enriched) {
|
||||
try {
|
||||
$this->travelSnapshotService->upsertFromTravel($travel);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -126,6 +131,7 @@ class TravelDataService
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('Travel data loaded from XML', [
|
||||
'dateId' => $dateId,
|
||||
@@ -140,7 +146,8 @@ class TravelDataService
|
||||
* Retrieve travel data from locally persisted sources.
|
||||
*
|
||||
* Runtime local reads prefer snapshots for performance and only fall back to XML
|
||||
* when no snapshot payload is available.
|
||||
* when no snapshot payload is available. Insurance data is always refreshed from
|
||||
* the XML loader to replace any stale snapshot values.
|
||||
*/
|
||||
public function getTravelDataFromLocal(int $dateId, ?int $hotelId = null): ?Travel
|
||||
{
|
||||
@@ -148,18 +155,73 @@ class TravelDataService
|
||||
?? $this->getTravelDataFromXml($dateId, $hotelId);
|
||||
|
||||
if (null !== $travel) {
|
||||
$this->patchInsuranceData($travel);
|
||||
$this->travelEnrichmentService->patchInsurances($travel);
|
||||
}
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function getTravelDataFromSnapshot(int $dateId, ?int $hotelId): ?Travel
|
||||
/**
|
||||
* Retrieve travel data specifically from remote API.
|
||||
*
|
||||
* Loads travel data from the remote BusProNet API. Note that the API uses
|
||||
* product IDs rather than date IDs, so mapping is performed internally.
|
||||
*
|
||||
* @param int $dateId The travel date ID to retrieve
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
*
|
||||
* @return Travel|null The travel data or null if not found via API
|
||||
*/
|
||||
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
|
||||
{
|
||||
try {
|
||||
$travel = $this->travelSnapshotService->loadTravel($dateId, $hotelId);
|
||||
$productId = $this->travelLookupService->mapDateIdToProductId($dateId);
|
||||
if (null === $productId) {
|
||||
$this->logger->debug('Cannot map dateId to productId for API call', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->apiClient->getTravelData($productId, $hotelId);
|
||||
|
||||
if (!$result instanceof Travel) {
|
||||
$this->logger->debug('API returned non-travel result', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'productId' => $productId,
|
||||
'resultType' => get_class($result),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->debug('Travel data loaded from API', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'productId' => $productId,
|
||||
'travelId' => $result->id,
|
||||
]);
|
||||
|
||||
$this->travelEnrichmentService->patchInsurances($result);
|
||||
|
||||
// Persist snapshot separately: a DB/serializer failure must not discard a
|
||||
// successfully fetched remote travel or make source=remote unreliable.
|
||||
try {
|
||||
$this->travelSnapshotService->upsertFromTravel($result);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Snapshot lookup failed, falling back to XML', [
|
||||
$this->logger->warning('Failed to persist travel snapshot after API load', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (ApiClientException $e) {
|
||||
$this->logger->error('Failed to load travel data from API', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'error' => $e->getMessage(),
|
||||
@@ -167,18 +229,6 @@ class TravelDataService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $travel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->debug('Travel data loaded from local snapshot', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'travelId' => $travel->id,
|
||||
]);
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,72 +281,112 @@ class TravelDataService
|
||||
return ['processed' => $processed, 'failed' => $failed];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mutability
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve travel data specifically from remote API.
|
||||
* Gets mutability data for a travel date.
|
||||
*
|
||||
* Loads travel data from the remote BusProNet API. Note that the API uses
|
||||
* product IDs rather than date IDs, so mapping is performed internally.
|
||||
* @param int $dateId The travel date ID for API call
|
||||
* @param bool $cached Whether to use cached data (default: true, TTL: 5 minutes)
|
||||
* @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true)
|
||||
*
|
||||
* @param int $dateId The travel date ID to retrieve
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
*
|
||||
* @return Travel|null The travel data or null if not found via API
|
||||
* @return BaseData|null The mutability data or null if not available or error occurred
|
||||
*/
|
||||
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
|
||||
public function getMutabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?BaseData
|
||||
{
|
||||
try {
|
||||
// Map dateId to productId for API call
|
||||
$productId = $this->mapDateIdToProductId($dateId);
|
||||
if (null === $productId) {
|
||||
$this->logger->debug('Cannot map dateId to productId for API call', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
]);
|
||||
|
||||
return null;
|
||||
if ($cached) {
|
||||
return $this->fetchCached(
|
||||
sprintf('mutability_%d', $dateId),
|
||||
self::MUTABILITY_CACHE_TTL,
|
||||
fn () => $this->fetchMutabilityData($dateId),
|
||||
$forceRefresh,
|
||||
);
|
||||
}
|
||||
|
||||
$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;
|
||||
return $this->fetchMutabilityData($dateId);
|
||||
}
|
||||
|
||||
$this->logger->debug('Travel data loaded from API', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'productId' => $productId,
|
||||
'travelId' => $result->id,
|
||||
/**
|
||||
* Apply mutability data to travel services.
|
||||
*
|
||||
* @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,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->patchInsuranceData($result);
|
||||
$this->travelSnapshotService->upsertFromTravel($result);
|
||||
// -------------------------------------------------------------------------
|
||||
// Availability
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return $result;
|
||||
} catch (ApiClientException $e) {
|
||||
$this->logger->error('Failed to load travel data from API', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
'error' => $e->getMessage(),
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Performs a lightweight check to determine if travel data can be served from
|
||||
* local sources without loading the full travel object.
|
||||
*
|
||||
* @param int $dateId The travel date ID to check
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
*
|
||||
@@ -304,30 +394,12 @@ class TravelDataService
|
||||
*/
|
||||
public function existsLocally(int $dateId, ?int $hotelId = null): bool
|
||||
{
|
||||
if (true === $this->travelSnapshotService->exists($dateId, $hotelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$mapping = $this->generateFilesMap();
|
||||
|
||||
if (false === isset($mapping[$dateId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If hotelId is specified, check if it exists in the travel's hotels
|
||||
if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->travelLookupService->existsLocally($dateId, $hotelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about available data sources for a travel.
|
||||
*
|
||||
* Returns information about which data sources (local persisted, remote API, or both) have
|
||||
* data available for the specified travel.
|
||||
*
|
||||
* @param int $dateId The travel date ID to check
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
*
|
||||
@@ -335,37 +407,97 @@ class TravelDataService
|
||||
*/
|
||||
public function getAvailableSources(int $dateId, ?int $hotelId = null): array
|
||||
{
|
||||
return [
|
||||
static::SOURCE_LOCAL => $this->existsLocally($dateId, $hotelId),
|
||||
static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId),
|
||||
];
|
||||
return $this->travelLookupService->getAvailableSources($dateId, $hotelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load travel data directly without caching.
|
||||
* Map date code to date ID.
|
||||
*
|
||||
* Internal method that handles the actual loading logic with fallback support.
|
||||
* Tries the preferred source first, then falls back to the alternative if enabled.
|
||||
* @param string $dateCode The date code to map
|
||||
*
|
||||
* @param int $dateId The travel date ID to retrieve
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
* @param bool $preferRemote Whether to prefer remote API over local sources
|
||||
*
|
||||
* @return Travel|null The travel data or null if not found
|
||||
* @return int|null The corresponding date ID or null if not found
|
||||
*/
|
||||
public function mapDateCodeToId(string $dateCode): ?int
|
||||
{
|
||||
return $this->travelLookupService->mapDateCodeToId($dateCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map hotel code to hotel ID.
|
||||
*
|
||||
* @param string $hotelCode The hotel code to map
|
||||
*
|
||||
* @return int|null The corresponding hotel ID or null if not found
|
||||
*/
|
||||
public function mapHotelCodeToId(string $hotelCode): ?int
|
||||
{
|
||||
return $this->travelLookupService->mapHotelCodeToId($hotelCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map date ID to product ID for API calls.
|
||||
*
|
||||
* @param int $dateId The date ID to map
|
||||
*
|
||||
* @return int|null The corresponding product ID or null if not found
|
||||
*/
|
||||
public function mapDateIdToProductId(int $dateId): ?int
|
||||
{
|
||||
return $this->travelLookupService->mapDateIdToProductId($dateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate files mapping for available travel data.
|
||||
*
|
||||
* @return array<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
|
||||
{
|
||||
$primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL;
|
||||
$fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE;
|
||||
|
||||
// Try primary source first
|
||||
$travel = $this->loadFromSource($dateId, $hotelId, $primarySource);
|
||||
|
||||
if (null !== $travel) {
|
||||
return $travel;
|
||||
}
|
||||
|
||||
// Try fallback source if enabled
|
||||
if (true === $this->enableFallback) {
|
||||
$this->logger->debug('Fallback to alternative source', [
|
||||
'dateId' => $dateId,
|
||||
@@ -380,17 +512,6 @@ class TravelDataService
|
||||
return $travel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load travel data from a specific source.
|
||||
*
|
||||
* Internal method that routes to the appropriate loader based on source type.
|
||||
*
|
||||
* @param int $dateId The travel date ID to retrieve
|
||||
* @param int|null $hotelId Optional hotel ID for specific hotel data
|
||||
* @param string $source The source type (SOURCE_LOCAL or SOURCE_REMOTE)
|
||||
*
|
||||
* @return Travel|null The travel data or null if not found
|
||||
*/
|
||||
private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel
|
||||
{
|
||||
return match ($source) {
|
||||
@@ -400,207 +521,6 @@ class TravelDataService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map date code to date ID.
|
||||
*
|
||||
* Converts a date code string to its corresponding date ID using the
|
||||
* date loader's mapping functionality.
|
||||
*
|
||||
* @param string $dateCode The date code to map
|
||||
*
|
||||
* @return int|null The corresponding date ID or null if not found
|
||||
*/
|
||||
public function mapDateCodeToId(string $dateCode): ?int
|
||||
{
|
||||
try {
|
||||
$mapping = $this->generateFilesMap();
|
||||
$dateCodes = array_column($mapping, 'code', 'id');
|
||||
$dateId = array_search($dateCode, $dateCodes, true);
|
||||
|
||||
if (false === $dateId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->debug('Date code mapping', [
|
||||
'dateCode' => $dateCode,
|
||||
'dateId' => $dateId,
|
||||
]);
|
||||
|
||||
return (int) $dateId;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to map date code to ID', [
|
||||
'dateCode' => $dateCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map hotel code to hotel ID.
|
||||
*
|
||||
* Converts a hotel code string to its corresponding hotel ID using the
|
||||
* hotel loader's mapping functionality.
|
||||
*
|
||||
* @param string $hotelCode The hotel code to map
|
||||
*
|
||||
* @return int|null The corresponding hotel ID or null if not found
|
||||
*/
|
||||
public function mapHotelCodeToId(string $hotelCode): ?int
|
||||
{
|
||||
try {
|
||||
$hotelId = $this->hotelLoader->mapCodeToId($hotelCode);
|
||||
|
||||
$this->logger->debug('Hotel code mapping', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'hotelId' => $hotelId,
|
||||
]);
|
||||
|
||||
return $hotelId;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to map hotel code to ID', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map date ID to product ID for API calls.
|
||||
*
|
||||
* Converts a date ID to its corresponding product ID for use with the
|
||||
* remote API. This method exposes the existing loader functionality
|
||||
* through the service layer.
|
||||
*
|
||||
* @param int $dateId The date ID to map
|
||||
*
|
||||
* @return int|null The corresponding product ID or null if not found
|
||||
*/
|
||||
public function mapDateIdToProductId(int $dateId): ?int
|
||||
{
|
||||
try {
|
||||
$productId = $this->travelSnapshotService->findProductIdByDateId($dateId);
|
||||
$productId = $productId ?? $this->travelLoader->mapDateIdToProductId($dateId);
|
||||
|
||||
$this->logger->debug('Date ID to product ID mapping', [
|
||||
'dateId' => $dateId,
|
||||
'productId' => $productId,
|
||||
]);
|
||||
|
||||
return $productId;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to map date ID to product ID', [
|
||||
'dateId' => $dateId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate files mapping for available travel data.
|
||||
*
|
||||
* Creates a mapping of all available travel data files with their
|
||||
* corresponding travel and hotel information. This method exposes
|
||||
* the existing loader functionality through the service layer.
|
||||
*
|
||||
* @return array<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
|
||||
{
|
||||
try {
|
||||
@@ -631,65 +551,6 @@ class TravelDataService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply mutability data to travel services.
|
||||
*
|
||||
* Updates the mutability status of various travel services based on the
|
||||
* provided mutability configuration data.
|
||||
*
|
||||
* @param Travel $travel The travel object to update
|
||||
* @param BaseData $mutableData The mutability configuration data
|
||||
*/
|
||||
public function patchMutability(Travel $travel, BaseData $mutableData): void
|
||||
{
|
||||
$this->travelLoader->patchMutability($travel, $mutableData);
|
||||
|
||||
$this->logger->debug('Successfully patched mutability data', [
|
||||
'travelId' => $travel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets availability data for a travel date.
|
||||
*
|
||||
* @param int $dateId The travel date ID for API call
|
||||
* @param bool $cached Whether to use cached data (default: false)
|
||||
* @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true)
|
||||
*
|
||||
* @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred
|
||||
*/
|
||||
public function getAvailabilityData(int $dateId, bool $cached = false, bool $forceRefresh = false): ?ServiceAvailabilityResponse
|
||||
{
|
||||
if ($cached) {
|
||||
$cacheKey = sprintf('availability_%d', $dateId);
|
||||
|
||||
try {
|
||||
if ($forceRefresh) {
|
||||
$this->cache->delete($cacheKey);
|
||||
}
|
||||
|
||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) {
|
||||
$item->expiresAfter(self::AVAILABILITY_CACHE_TTL);
|
||||
|
||||
return $this->fetchAvailabilityData($dateId);
|
||||
});
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->error('Cache error in getAvailabilityData', [
|
||||
'dateId' => $dateId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Fallback to direct API call
|
||||
return $this->fetchAvailabilityData($dateId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fetchAvailabilityData($dateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches availability data directly from the API without caching.
|
||||
*/
|
||||
private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse
|
||||
{
|
||||
try {
|
||||
@@ -721,85 +582,36 @@ class TravelDataService
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply availability data to travel services.
|
||||
* Fetch a value from cache, computing it via $fetcher on a miss.
|
||||
*
|
||||
* Updates the availability status of additional and transportation
|
||||
* services, and the allowed booking status, based on the provided
|
||||
* availability data.
|
||||
* On cache error the value is computed directly so callers are never blocked.
|
||||
* Pass $forceRefresh=true to delete the cached entry before fetching.
|
||||
*
|
||||
* @param Travel $travel The travel object to update
|
||||
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
||||
* @template T
|
||||
*
|
||||
* @param callable(): T $fetcher
|
||||
*
|
||||
* @return T|null
|
||||
*/
|
||||
public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void
|
||||
{
|
||||
$this->travelLoader->patchAvailabilities($travel, $availabilities);
|
||||
|
||||
$this->logger->debug('Successfully patched availability data', [
|
||||
'travelId' => $travel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches travel data with fresh availability information from the API.
|
||||
*
|
||||
* Fetches cached availability data and patches it onto the travel object.
|
||||
* Used by controllers to ensure availability data is up-to-date before
|
||||
* rendering forms or processing submissions.
|
||||
*
|
||||
* @param Travel $travel The travel object to enrich
|
||||
* @param bool $cached Whether to use cached availability data (default: true)
|
||||
*/
|
||||
public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void
|
||||
{
|
||||
$availabilities = $this->getAvailabilityData($travel->id, $cached);
|
||||
|
||||
if (null !== $availabilities) {
|
||||
$this->patchAvailabilities($travel, $availabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich travel data with additional information.
|
||||
*
|
||||
* Adds hotel details and pickup information to travel data loaded from XML.
|
||||
* This enrichment is necessary for complete travel information.
|
||||
*
|
||||
* @param Travel $travel The travel object to enrich
|
||||
*/
|
||||
private function enrichTravelData(Travel $travel): void
|
||||
private function fetchCached(string $cacheKey, int $ttl, callable $fetcher, bool $forceRefresh): mixed
|
||||
{
|
||||
try {
|
||||
$this->pickupLoader->patchPickupsDetails($travel);
|
||||
$this->hotelLoader->patchHotelDetails($travel);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to enrich travel data', [
|
||||
'travelId' => $travel->id,
|
||||
if ($forceRefresh) {
|
||||
$this->cache->delete($cacheKey);
|
||||
}
|
||||
|
||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($ttl, $fetcher) {
|
||||
$item->expiresAfter($ttl);
|
||||
|
||||
return $fetcher();
|
||||
});
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->error('Cache error', [
|
||||
'key' => $cacheKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
]);
|
||||
return $fetcher();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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\ParticipantPricingCalculator;
|
||||
use App\Service\RoomPricingCalculator;
|
||||
use App\Service\ServicePricingCalculator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookingPriceCalculatorServiceTest extends TestCase
|
||||
{
|
||||
private BookingPriceCalculatorService $service;
|
||||
private ParticipantEligibilityService $participantEligibilityService;
|
||||
private RoomPricingCalculator $roomPricingCalculator;
|
||||
private ParticipantPricingCalculator $participantPricingCalculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
|
||||
$this->participantEligibilityService->method('isParticipantEligible')->willReturn(true);
|
||||
|
||||
// Build the calculator chain
|
||||
$roomPricingCalculator = new RoomPricingCalculator();
|
||||
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
|
||||
$this->roomPricingCalculator = new RoomPricingCalculator();
|
||||
$this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator);
|
||||
|
||||
// Create InsuranceService with a mock price calculator (to avoid circular reference in tests)
|
||||
$mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$insuranceService = new InsuranceService($mockPriceCalculator);
|
||||
|
||||
$servicePricingCalculator = new ServicePricingCalculator(
|
||||
$this->participantEligibilityService,
|
||||
$insuranceService,
|
||||
$participantPricingCalculator
|
||||
);
|
||||
|
||||
$this->service = new BookingPriceCalculatorService(
|
||||
$roomPricingCalculator,
|
||||
$servicePricingCalculator,
|
||||
$participantPricingCalculator
|
||||
);
|
||||
$this->service = $this->createService($this->participantEligibilityService);
|
||||
}
|
||||
|
||||
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
|
||||
@@ -613,24 +599,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
|
||||
$participantEligibilityService->method('isParticipantEligible')
|
||||
->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible
|
||||
|
||||
// Build the calculator chain with the custom eligibility service
|
||||
$roomPricingCalculator = new RoomPricingCalculator();
|
||||
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
|
||||
|
||||
$mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$insuranceService = new InsuranceService($mockPriceCalculator);
|
||||
|
||||
$servicePricingCalculator = new ServicePricingCalculator(
|
||||
$participantEligibilityService,
|
||||
$insuranceService,
|
||||
$participantPricingCalculator
|
||||
);
|
||||
|
||||
$this->service = new BookingPriceCalculatorService(
|
||||
$roomPricingCalculator,
|
||||
$servicePricingCalculator,
|
||||
$participantPricingCalculator
|
||||
);
|
||||
$this->service = $this->createService($participantEligibilityService);
|
||||
|
||||
$result = $this->service->calculateServicePricing($bookingDto);
|
||||
|
||||
@@ -725,4 +694,14 @@ class BookingPriceCalculatorServiceTest extends TestCase
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
private function createService(ParticipantEligibilityService $participantEligibilityService): BookingPriceCalculatorService
|
||||
{
|
||||
return new BookingPriceCalculatorService(
|
||||
$this->roomPricingCalculator,
|
||||
$participantEligibilityService,
|
||||
new InsuranceService(),
|
||||
$this->participantPricingCalculator,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\InsuranceLoader;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\Exception\TravelNotFoundException;
|
||||
use App\Service\TravelDataService;
|
||||
use App\Service\TravelEnrichmentService;
|
||||
use App\Service\TravelLookupService;
|
||||
use App\Service\TravelSnapshotService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -22,34 +20,31 @@ class TravelDataServiceTest extends TestCase
|
||||
{
|
||||
private TravelDataService $service;
|
||||
private TravelLoader $travelLoader;
|
||||
private HotelLoader $hotelLoader;
|
||||
private PickupLoader $pickupLoader;
|
||||
private InsuranceLoader $insuranceLoader;
|
||||
private ApiClient $apiClient;
|
||||
private CacheInterface $cache;
|
||||
private LoggerInterface $logger;
|
||||
private TravelSnapshotService $travelSnapshotService;
|
||||
private TravelLookupService $travelLookupService;
|
||||
private TravelEnrichmentService $travelEnrichmentService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->travelLoader = $this->createMock(TravelLoader::class);
|
||||
$this->hotelLoader = $this->createMock(HotelLoader::class);
|
||||
$this->pickupLoader = $this->createMock(PickupLoader::class);
|
||||
$this->insuranceLoader = $this->createMock(InsuranceLoader::class);
|
||||
$this->apiClient = $this->createMock(ApiClient::class);
|
||||
$this->cache = $this->createMock(CacheInterface::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->travelSnapshotService = $this->createMock(TravelSnapshotService::class);
|
||||
$this->travelLookupService = $this->createMock(TravelLookupService::class);
|
||||
$this->travelEnrichmentService = $this->createMock(TravelEnrichmentService::class);
|
||||
|
||||
$this->service = new TravelDataService(
|
||||
$this->travelLoader,
|
||||
$this->hotelLoader,
|
||||
$this->pickupLoader,
|
||||
$this->insuranceLoader,
|
||||
$this->apiClient,
|
||||
$this->cache,
|
||||
$this->logger,
|
||||
$this->travelSnapshotService,
|
||||
$this->travelLookupService,
|
||||
$this->travelEnrichmentService,
|
||||
false, // preferRemote
|
||||
true, // enableFallback
|
||||
);
|
||||
@@ -69,15 +64,45 @@ class TravelDataServiceTest extends TestCase
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->pickupLoader
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('patchPickupsDetails')
|
||||
->method('enrichFromXml')
|
||||
->with($travel)
|
||||
->willReturn(true);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('upsertFromTravel')
|
||||
->with($travel);
|
||||
|
||||
$this->hotelLoader
|
||||
$result = $this->service->getTravelDataFromXml($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromXmlSkipsSnapshotWhenEnrichmentFails(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('patchHotelDetails')
|
||||
->with($travel);
|
||||
->method('loadById')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('enrichFromXml')
|
||||
->with($travel)
|
||||
->willReturn(false);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->never())
|
||||
->method('upsertFromTravel');
|
||||
|
||||
$result = $this->service->getTravelDataFromXml($dateId, $hotelId);
|
||||
|
||||
@@ -95,13 +120,9 @@ class TravelDataServiceTest extends TestCase
|
||||
->with($dateId, $hotelId)
|
||||
->willThrowException(new TravelNotFoundException($dateId));
|
||||
|
||||
$this->pickupLoader
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->never())
|
||||
->method('patchPickupsDetails');
|
||||
|
||||
$this->hotelLoader
|
||||
->expects($this->never())
|
||||
->method('patchHotelDetails');
|
||||
->method('enrichFromXml');
|
||||
|
||||
$this->expectException(TravelNotFoundException::class);
|
||||
$this->service->getTravelDataFromXml($dateId, $hotelId);
|
||||
@@ -116,7 +137,7 @@ class TravelDataServiceTest extends TestCase
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->travelLoader
|
||||
$this->travelLookupService
|
||||
->expects($this->once())
|
||||
->method('mapDateIdToProductId')
|
||||
->with($dateId)
|
||||
@@ -128,17 +149,60 @@ class TravelDataServiceTest extends TestCase
|
||||
->with($productId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('patchInsurances')
|
||||
->with($travel);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('upsertFromTravel')
|
||||
->with($travel);
|
||||
|
||||
$result = $this->service->getTravelDataFromApi($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromApiSnapshotFailureDoesNotDiscardApiTravel(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$productId = 555;
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->travelLookupService
|
||||
->method('mapDateIdToProductId')
|
||||
->willReturn($productId);
|
||||
|
||||
$this->apiClient
|
||||
->method('getTravelData')
|
||||
->willReturn($travel);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('upsertFromTravel')
|
||||
->willThrowException(new \RuntimeException('DB write failed'));
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Failed to persist travel snapshot after API load', $this->arrayHasKey('dateId'));
|
||||
|
||||
$result = $this->service->getTravelDataFromApi($dateId, $hotelId);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromApiCannotMapDateId(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
|
||||
$this->travelLoader
|
||||
$this->travelLookupService
|
||||
->expects($this->once())
|
||||
->method('mapDateIdToProductId')
|
||||
->with($dateId)
|
||||
@@ -153,175 +217,45 @@ class TravelDataServiceTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testExistsLocallyTrueFromSnapshot(): void
|
||||
public function testExistsLocallyDelegatesToLookupService(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
|
||||
$this->travelSnapshotService
|
||||
$this->travelLookupService
|
||||
->expects($this->once())
|
||||
->method('exists')
|
||||
->method('existsLocally')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(true);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->never())
|
||||
->method('generateFilesMap');
|
||||
|
||||
$result = $this->service->existsLocally($dateId, $hotelId);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertTrue($this->service->existsLocally($dateId, $hotelId));
|
||||
}
|
||||
|
||||
public function testExistsLocallyTrueFromXmlMap(): void
|
||||
public function testGetAvailableSourcesDelegatesToLookupService(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$mapping = [
|
||||
$dateId => [
|
||||
'id' => $dateId,
|
||||
'hotels' => [
|
||||
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
|
||||
],
|
||||
],
|
||||
];
|
||||
$expected = ['local' => true, 'remote' => true];
|
||||
|
||||
$this->travelSnapshotService
|
||||
$this->travelLookupService
|
||||
->expects($this->once())
|
||||
->method('exists')
|
||||
->method('getAvailableSources')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(false);
|
||||
->willReturn($expected);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('generateFilesMap')
|
||||
->willReturn($mapping);
|
||||
|
||||
$result = $this->service->existsLocally($dateId, $hotelId);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame($expected, $this->service->getAvailableSources($dateId, $hotelId));
|
||||
}
|
||||
|
||||
public function testExistsLocallyFalseNoTravel(): void
|
||||
public function testGenerateFilesMapDelegatesToLookupService(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$mapping = [];
|
||||
$mapping = [12345 => ['id' => 12345, 'hotels' => []]];
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('exists')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(false);
|
||||
|
||||
$this->travelLoader
|
||||
$this->travelLookupService
|
||||
->expects($this->once())
|
||||
->method('generateFilesMap')
|
||||
->willReturn($mapping);
|
||||
|
||||
$result = $this->service->existsLocally($dateId, $hotelId);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testExistsLocallyFalseNoHotel(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$mapping = [
|
||||
$dateId => [
|
||||
'id' => $dateId,
|
||||
'hotels' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('exists')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(false);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('generateFilesMap')
|
||||
->willReturn($mapping);
|
||||
|
||||
$result = $this->service->existsLocally($dateId, $hotelId);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testGetAvailableSources(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$productId = 555;
|
||||
$mapping = [
|
||||
$dateId => [
|
||||
'id' => $dateId,
|
||||
'hotels' => [
|
||||
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('exists')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(false);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('generateFilesMap')
|
||||
->willReturn($mapping);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('mapDateIdToProductId')
|
||||
->with($dateId)
|
||||
->willReturn($productId);
|
||||
|
||||
$result = $this->service->getAvailableSources($dateId, $hotelId);
|
||||
|
||||
$this->assertEquals([
|
||||
'local' => true,
|
||||
'remote' => true,
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function testGenerateFilesMapIsMemoizedWithinRequest(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$mapping = [
|
||||
$dateId => [
|
||||
'id' => $dateId,
|
||||
'hotels' => [
|
||||
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Both existsLocally() calls go through generateFilesMap(); the underlying
|
||||
// loader and snapshot service must each be invoked only once.
|
||||
$this->travelSnapshotService
|
||||
->expects($this->exactly(2))
|
||||
->method('exists')
|
||||
->willReturn(false);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('generateFilesMap')
|
||||
->willReturn($mapping);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('generateMapping')
|
||||
->willReturn([]);
|
||||
|
||||
$this->service->existsLocally($dateId, $hotelId);
|
||||
$this->service->existsLocally($dateId, $hotelId);
|
||||
$this->assertSame($mapping, $this->service->generateFilesMap());
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromLocalPrefersSnapshot(): void
|
||||
@@ -342,53 +276,14 @@ class TravelDataServiceTest extends TestCase
|
||||
->expects($this->never())
|
||||
->method('loadById');
|
||||
|
||||
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromLocalReplacesSnapshotInsurancesWithLoaderData(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
// Snapshot-provided insurances are intentionally replaced by InsuranceLoader data.
|
||||
$staleSnapshotInsurance = new Insurance();
|
||||
$staleSnapshotInsurance->id = 'stale';
|
||||
$travel->insurances = [$staleSnapshotInsurance];
|
||||
|
||||
$individual = new Insurance();
|
||||
$individual->id = '10';
|
||||
$individual->package = false;
|
||||
|
||||
$package = new Insurance();
|
||||
$package->id = '20';
|
||||
$package->package = true;
|
||||
$package->containedInsuranceIds = ['10'];
|
||||
// InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data.
|
||||
$package->containedInsurances = [$individual];
|
||||
|
||||
$this->travelSnapshotService
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('loadTravel')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->insuranceLoader
|
||||
->expects($this->once())
|
||||
->method('loadAll')
|
||||
->willReturn([$individual, $package]);
|
||||
->method('patchInsurances')
|
||||
->with($travel);
|
||||
|
||||
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
$this->assertSame([$individual, $package], array_values($travel->insurances));
|
||||
$this->assertCount(1, $package->containedInsurances);
|
||||
$this->assertSame($individual, $package->containedInsurances[0]);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void
|
||||
@@ -411,105 +306,23 @@ class TravelDataServiceTest extends TestCase
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->pickupLoader
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('patchPickupsDetails')
|
||||
->with($travel);
|
||||
|
||||
$this->hotelLoader
|
||||
->expects($this->once())
|
||||
->method('patchHotelDetails')
|
||||
->with($travel);
|
||||
->method('enrichFromXml')
|
||||
->with($travel)
|
||||
->willReturn(true);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('upsertFromTravel')
|
||||
->with($travel);
|
||||
|
||||
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataFromLocalAttachesLoaderInsurancesFromXmlFallback(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
|
||||
$individual = new Insurance();
|
||||
$individual->id = '10';
|
||||
$individual->package = false;
|
||||
|
||||
$package = new Insurance();
|
||||
$package->id = '20';
|
||||
$package->package = true;
|
||||
$package->containedInsuranceIds = ['10'];
|
||||
// InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data.
|
||||
$package->containedInsurances = [$individual];
|
||||
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->travelSnapshotService
|
||||
$this->travelEnrichmentService
|
||||
->expects($this->once())
|
||||
->method('loadTravel')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(null);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('loadById')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->pickupLoader->expects($this->once())->method('patchPickupsDetails')->with($travel);
|
||||
$this->hotelLoader->expects($this->once())->method('patchHotelDetails')->with($travel);
|
||||
$this->insuranceLoader->expects($this->once())->method('loadAll')->willReturn([$individual, $package]);
|
||||
$this->travelSnapshotService->expects($this->once())->method('upsertFromTravel')->with($travel);
|
||||
|
||||
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
$this->assertCount(1, $package->containedInsurances);
|
||||
$this->assertSame($individual, $package->containedInsurances[0]);
|
||||
}
|
||||
|
||||
public function testGetTravelDataDelegatesToLoadUncached(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->cache
|
||||
->expects($this->never())
|
||||
->method('get');
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('loadTravel')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(null);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('loadById')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->pickupLoader
|
||||
->expects($this->once())
|
||||
->method('patchPickupsDetails')
|
||||
->method('patchInsurances')
|
||||
->with($travel);
|
||||
|
||||
$this->hotelLoader
|
||||
->expects($this->once())
|
||||
->method('patchHotelDetails')
|
||||
->with($travel);
|
||||
|
||||
$result = $this->service->getTravelData($dateId, $hotelId, false);
|
||||
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
@@ -579,6 +392,10 @@ class TravelDataServiceTest extends TestCase
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$this->travelEnrichmentService
|
||||
->method('enrichFromXml')
|
||||
->willReturn(true);
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('upsertFromTravel')
|
||||
@@ -594,4 +411,33 @@ class TravelDataServiceTest extends TestCase
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testGetTravelDataDelegatesToLoadUncached(): void
|
||||
{
|
||||
$dateId = 12345;
|
||||
$hotelId = 67890;
|
||||
$travel = new Travel();
|
||||
$travel->id = $dateId;
|
||||
$travel->hotelId = $hotelId;
|
||||
|
||||
$this->cache
|
||||
->expects($this->never())
|
||||
->method('get');
|
||||
|
||||
$this->travelSnapshotService
|
||||
->expects($this->once())
|
||||
->method('loadTravel')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn(null);
|
||||
|
||||
$this->travelLoader
|
||||
->expects($this->once())
|
||||
->method('loadById')
|
||||
->with($dateId, $hotelId)
|
||||
->willReturn($travel);
|
||||
|
||||
$result = $this->service->getTravelData($dateId, $hotelId, false);
|
||||
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user