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