From 8ff13614d2835e36bdec64ef8b899b1da3fc99d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 4 Aug 2026 11:29:58 +0200 Subject: [PATCH] fix: treat dates without price as unavailable --- src/Controller/Api/ContingentController.php | 15 +- src/Controller/Groups/Step1Controller.php | 46 +++-- src/Service/AccommodationPriceCoverage.php | 60 +++++++ src/Twig/Components/Calendar.php | 14 ++ .../Accommodation/Step1ControllerTest.php | 8 +- .../Api/ContingentControllerTest.php | 59 ++++++- .../Groups/Step1CalendarDataTest.php | 160 ++++++++++++++++++ .../AccommodationPriceCoverageTest.php | 68 ++++++++ 8 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 src/Service/AccommodationPriceCoverage.php create mode 100644 tests/Controller/Groups/Step1CalendarDataTest.php create mode 100644 tests/Service/AccommodationPriceCoverageTest.php diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 4d5fe8b..2499202 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -6,12 +6,14 @@ namespace App\Controller\Api; use App\BpnConnect\ContingentsClient; use App\BpnConnect\Exception\BpnConnectException; +use App\BpnConnect\Model\ContingentStatus; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\PriceType; use App\Model\ContingentCalendarQuery; use App\Model\ContingentPricesQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; +use App\Service\AccommodationPriceCoverage; use App\Service\PriceTimelineBuilder; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; @@ -34,6 +36,7 @@ class ContingentController extends AbstractController private readonly AccommodationPriceRepository $priceRepository, private readonly CacheInterface $cache, private readonly PriceTimelineBuilder $priceTimelineBuilder, + private readonly AccommodationPriceCoverage $priceCoverage, private readonly LoggerInterface $logger, ) { } @@ -96,8 +99,10 @@ class ContingentController extends AbstractController $currency = $accommodation->getCurrency(); + $covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo); + $data = array_map( - fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency), + fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $covered, $currency), $calendar->data, ); @@ -106,13 +111,19 @@ class ContingentController extends AbstractController /** * @param AccommodationPrice[] $prices + * @param array $covered * * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null} */ - private function enrichEntry(string $date, string $status, array $prices, string $currency): array + private function enrichEntry(string $date, string $status, array $prices, array $covered, string $currency): array { $day = (new \DateTimeImmutable($date))->setTime(0, 0); + // A day without a price is not sold, no matter what the contingent says. + if (!isset($covered[$day->format('Y-m-d')])) { + $status = ContingentStatus::Blocked->value; + } + // dateFrom and dateTo are both inclusive (last night, not checkout day) $candidates = array_filter( $prices, diff --git a/src/Controller/Groups/Step1Controller.php b/src/Controller/Groups/Step1Controller.php index bb38b22..9096b6d 100644 --- a/src/Controller/Groups/Step1Controller.php +++ b/src/Controller/Groups/Step1Controller.php @@ -13,6 +13,7 @@ use App\Htmx\HxTrait; use App\Model\AccommodationBookingContext; use App\Repository\Groups\AccommodationPriceRepository; use App\Service\AccommodationBookingService; +use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationSessionManager; use App\Service\CalendarGridBuilder; use App\Service\GroupsPriceCalculator; @@ -38,6 +39,7 @@ class Step1Controller extends AbstractAccommodationController private readonly CacheInterface $cache, private readonly CalendarGridBuilder $calendarGridBuilder, private readonly GroupsPriceCalculator $priceCalculator, + private readonly AccommodationPriceCoverage $priceCoverage, ) { } @@ -225,9 +227,12 @@ class Step1Controller extends AbstractAccommodationController } /** - * Fetches contingent + price data and returns a map of date → ['status', 'minNights']. + * Fetches contingent + price data and returns a map of date → ['status', 'minNights'] + * covering every day of the requested range. * - * Returns an empty array on API failure; the template treats missing dates as blocked. + * A day is only available when the contingent allows it *and* an AccommodationPrice + * covers it. On API failure the contingent imposes no restriction and price coverage + * alone decides. * * @return array */ @@ -238,6 +243,7 @@ class Step1Controller extends AbstractAccommodationController string $dateFromStr, string $dateToStr, ): array { + $calendar = null; try { $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr); $calendar = $this->cache->get( @@ -249,23 +255,38 @@ class Step1Controller extends AbstractAccommodationController }, ); } catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) { - return []; + // Fall through with $calendar === null — price coverage still applies + } + + $contingentStatus = []; + if (null !== $calendar) { + foreach ($calendar->data as $entry) { + $contingentStatus[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = $entry->status; + } } $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + $covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo); + + $start = $dateFrom->setTime(0, 0); + $end = $dateTo->setTime(0, 0); $availableDates = []; - foreach ($calendar->data as $entry) { - if (ContingentStatus::Ok === $entry->status) { - $availableDates[$entry->date] = true; + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + $date = $day->format('Y-m-d'); + // Days the contingent API does not mention are unrestricted + $status = $contingentStatus[$date] ?? ContingentStatus::Ok; + + if (ContingentStatus::Ok === $status && isset($covered[$date])) { + $availableDates[$date] = true; } } $enriched = []; - foreach ($calendar->data as $entry) { - $isAvailable = ContingentStatus::Ok === $entry->status; - $prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d'); - $prevAvailable = isset($availableDates[$prevDate]); + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + $date = $day->format('Y-m-d'); + $isAvailable = isset($availableDates[$date]); + $prevAvailable = isset($availableDates[$day->modify('-1 day')->format('Y-m-d')]); $status = match (true) { $isAvailable && $prevAvailable => 'ok', @@ -274,14 +295,13 @@ class Step1Controller extends AbstractAccommodationController default => 'blocked', }; - $day = (new \DateTimeImmutable($entry->date))->setTime(0, 0); $candidates = array_values(array_filter( $prices, - fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + fn (AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, )); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); - $enriched[$entry->date] = [ + $enriched[$date] = [ 'status' => $status, 'minNights' => $winner?->getMinNights() ?? 0, ]; diff --git a/src/Service/AccommodationPriceCoverage.php b/src/Service/AccommodationPriceCoverage.php new file mode 100644 index 0000000..d455d37 --- /dev/null +++ b/src/Service/AccommodationPriceCoverage.php @@ -0,0 +1,60 @@ + keyed by Y-m-d, only covered days present + */ + public function coveredDates(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array + { + return $this->coveredDatesFor( + $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo), + $dateFrom, + $dateTo, + ); + } + + /** + * @param AccommodationPrice[] $prices + * + * @return array keyed by Y-m-d, only covered days present + */ + public function coveredDatesFor(array $prices, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array + { + $start = $dateFrom->setTime(0, 0); + $end = $dateTo->setTime(0, 0); + + $covered = []; + + // dateFrom and dateTo are both inclusive (last night, not checkout day) + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + foreach ($prices as $price) { + if ($price->getDateFrom() <= $day && $price->getDateTo() >= $day) { + $covered[$day->format('Y-m-d')] = true; + + break; + } + } + } + + return $covered; + } +} diff --git a/src/Twig/Components/Calendar.php b/src/Twig/Components/Calendar.php index 4da9a08..4196ef0 100644 --- a/src/Twig/Components/Calendar.php +++ b/src/Twig/Components/Calendar.php @@ -10,6 +10,7 @@ use App\BpnConnect\Model\ContingentStatus; use App\Entity\Groups\Accommodation; use App\Model\CalendarDay; use App\Model\CalendarMonth; +use App\Service\AccommodationPriceCoverage; use Carbon\CarbonImmutable; use Psr\Cache\InvalidArgumentException; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -45,6 +46,7 @@ class Calendar public function __construct( private readonly ContingentsClient $contingentsClient, private readonly CacheInterface $cache, + private readonly AccommodationPriceCoverage $priceCoverage, ) { } @@ -119,6 +121,18 @@ class Calendar } } + // A day without a price is not sold, no matter what the contingent says. + $rangeStart = new \DateTimeImmutable($dateFrom); + $rangeEnd = new \DateTimeImmutable($dateTo); + $covered = $this->priceCoverage->coveredDates($hotelCode, $rangeStart, $rangeEnd); + + for ($day = $rangeStart->setTime(0, 0); $day <= $rangeEnd->setTime(0, 0); $day = $day->modify('+1 day')) { + $key = $day->format('Y-m-d'); + if (!isset($covered[$key])) { + $blocked[$key] = true; + } + } + return $blocked; } diff --git a/tests/Controller/Accommodation/Step1ControllerTest.php b/tests/Controller/Accommodation/Step1ControllerTest.php index 7de0762..e991e07 100644 --- a/tests/Controller/Accommodation/Step1ControllerTest.php +++ b/tests/Controller/Accommodation/Step1ControllerTest.php @@ -10,6 +10,7 @@ use App\Entity\Groups\Accommodation; use App\Form\Model\AccommodationBookingDto; use App\Repository\Groups\AccommodationPriceRepository; use App\Service\AccommodationBookingService; +use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationSessionManager; use App\Service\CalendarGridBuilder; use App\Service\GroupsPriceCalculator; @@ -105,15 +106,18 @@ class Step1ControllerTest extends TestCase AccommodationBookingService $bookingService, AccommodationSessionManager $sessionManager, ): TestableAccommodationStep1Controller { + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + return new TestableAccommodationStep1Controller( $bookingService, $sessionManager, $this->createMock(ContingentsClient::class), - $this->createMock(AccommodationPriceRepository::class), + $priceRepository, new PriceTimelineBuilder(), $this->createMock(CacheInterface::class), new CalendarGridBuilder(), new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]), + new AccommodationPriceCoverage($priceRepository), ); } } @@ -129,6 +133,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller CacheInterface $cache, CalendarGridBuilder $calendarGridBuilder, GroupsPriceCalculator $priceCalculator, + AccommodationPriceCoverage $priceCoverage, ) { parent::__construct( $bookingService, @@ -139,6 +144,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller $cache, $calendarGridBuilder, $priceCalculator, + $priceCoverage, ); } diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php index 42ebe1b..81beff5 100644 --- a/tests/Controller/Api/ContingentControllerTest.php +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -9,22 +9,56 @@ use App\Controller\Api\ContingentController; use App\Entity\Groups\AccommodationPrice; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; +use App\Service\AccommodationPriceCoverage; use App\Service\PriceTimelineBuilder; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; class ContingentControllerTest extends TestCase { public function testEnrichEntryIncludesPricingMetadata(): void { - $controller = new ContingentController( + $controller = $this->createController(); + $price = $this->createPrice(); + + $result = $this->enrichEntry($controller, '2026-07-06', 'available', [$price], ['2026-07-06' => true]); + + self::assertSame('available', $result['status']); + self::assertSame(4, $result['includedPax']); + self::assertSame(3, $result['minNights']); + self::assertSame(123.45, $result['pricePerNight']); + self::assertSame('EUR', $result['currency']); + } + + public function testEnrichEntryBlocksDayWithoutPrice(): void + { + $controller = $this->createController(); + $price = $this->createPrice(); + + $result = $this->enrichEntry($controller, '2026-08-01', 'OK', [$price], ['2026-07-06' => true]); + + self::assertSame('BLOCKED', $result['status']); + self::assertNull($result['pricePerNight']); + self::assertNull($result['includedPax']); + self::assertNull($result['minNights']); + } + + private function createController(): ContingentController + { + return new ContingentController( $this->createMock(ContingentsClient::class), $this->createMock(AccommodationRepository::class), $this->createMock(AccommodationPriceRepository::class), $this->createMock(CacheInterface::class), new PriceTimelineBuilder(), + new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)), + $this->createMock(LoggerInterface::class), ); + } + private function createPrice(): AccommodationPrice + { $price = new AccommodationPrice(); $price->setDateFrom(new \DateTimeImmutable('2026-07-01')); $price->setDateTo(new \DateTimeImmutable('2026-07-31')); @@ -33,13 +67,24 @@ class ContingentControllerTest extends TestCase $price->setPriceAdditionalPerson(1500); $price->setMinNights(3); + return $price; + } + + /** + * @param AccommodationPrice[] $prices + * @param array $covered + * + * @return array + */ + private function enrichEntry( + ContingentController $controller, + string $date, + string $status, + array $prices, + array $covered, + ): array { $method = new \ReflectionMethod($controller, 'enrichEntry'); - $result = $method->invoke($controller, '2026-07-06', 'available', [$price], 'EUR'); - - self::assertSame(4, $result['includedPax']); - self::assertSame(3, $result['minNights']); - self::assertSame(123.45, $result['pricePerNight']); - self::assertSame('EUR', $result['currency']); + return $method->invoke($controller, $date, $status, $prices, $covered, 'EUR'); } } diff --git a/tests/Controller/Groups/Step1CalendarDataTest.php b/tests/Controller/Groups/Step1CalendarDataTest.php new file mode 100644 index 0000000..8803afc --- /dev/null +++ b/tests/Controller/Groups/Step1CalendarDataTest.php @@ -0,0 +1,160 @@ +setDateFrom(new \DateTimeImmutable('2026-06-03')); + $price->setDateTo(new \DateTimeImmutable('2026-06-05')); + $price->setMinNights(2); + + $enriched = $this->buildEnrichedDayData( + $this->calendarResponse([ + '2026-06-01' => ContingentStatus::Ok, + '2026-06-02' => ContingentStatus::Ok, + '2026-06-03' => ContingentStatus::Ok, + '2026-06-04' => ContingentStatus::Ok, + '2026-06-05' => ContingentStatus::Ok, + '2026-06-06' => ContingentStatus::Ok, + '2026-06-07' => ContingentStatus::Ok, + ]), + [$price], + '2026-06-01', + '2026-06-07', + ); + + self::assertSame('blocked', $enriched['2026-06-01']['status']); + self::assertSame('blocked', $enriched['2026-06-02']['status']); + // First priced night — arrival only + self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']); + self::assertSame('ok', $enriched['2026-06-04']['status']); + self::assertSame('ok', $enriched['2026-06-05']['status']); + // Day after the last priced night — still valid as checkout + self::assertSame('checkout-only', $enriched['2026-06-06']['status']); + self::assertSame('blocked', $enriched['2026-06-07']['status']); + + self::assertSame(2, $enriched['2026-06-04']['minNights']); + self::assertSame(0, $enriched['2026-06-07']['minNights']); + } + + public function testBlockedContingentWinsOverExistingPrice(): void + { + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable('2026-06-01')); + $price->setDateTo(new \DateTimeImmutable('2026-06-03')); + $price->setMinNights(1); + + $enriched = $this->buildEnrichedDayData( + $this->calendarResponse([ + '2026-06-01' => ContingentStatus::Ok, + '2026-06-02' => ContingentStatus::Blocked, + '2026-06-03' => ContingentStatus::Ok, + ]), + [$price], + '2026-06-01', + '2026-06-03', + ); + + self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']); + self::assertSame('checkout-only', $enriched['2026-06-02']['status']); + self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']); + } + + public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void + { + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable('2026-06-01')); + $price->setDateTo(new \DateTimeImmutable('2026-06-02')); + $price->setMinNights(1); + + $enriched = $this->buildEnrichedDayData(null, [$price], '2026-06-01', '2026-06-03'); + + self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']); + self::assertSame('ok', $enriched['2026-06-02']['status']); + self::assertSame('checkout-only', $enriched['2026-06-03']['status']); + } + + /** + * @param array $statuses + */ + private function calendarResponse(array $statuses): ContingentCalendarResponse + { + $entries = []; + foreach ($statuses as $date => $status) { + $entries[] = new ContingentCalendarEntry(date: $date, status: $status); + } + + return new ContingentCalendarResponse( + new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)), + $entries, + ); + } + + /** + * @param AccommodationPrice[] $prices + * + * @return array + */ + private function buildEnrichedDayData( + ?ContingentCalendarResponse $calendar, + array $prices, + string $dateFrom, + string $dateTo, + ): array { + $cache = $this->createMock(CacheInterface::class); + if (null === $calendar) { + $cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down')); + } else { + $cache->method('get')->willReturn($calendar); + } + + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); + + $controller = new Step1Controller( + $this->createMock(AccommodationBookingService::class), + $this->createMock(AccommodationSessionManager::class), + $this->createMock(ContingentsClient::class), + $priceRepository, + new PriceTimelineBuilder(), + $cache, + $this->createMock(CalendarGridBuilder::class), + $this->createMock(GroupsPriceCalculator::class), + new AccommodationPriceCoverage($priceRepository), + ); + + $method = new \ReflectionMethod($controller, 'buildEnrichedDayData'); + + return $method->invoke( + $controller, + 'HOTEL', + new \DateTimeImmutable($dateFrom), + new \DateTimeImmutable($dateTo), + $dateFrom, + $dateTo, + ); + } +} diff --git a/tests/Service/AccommodationPriceCoverageTest.php b/tests/Service/AccommodationPriceCoverageTest.php new file mode 100644 index 0000000..7b7a69a --- /dev/null +++ b/tests/Service/AccommodationPriceCoverageTest.php @@ -0,0 +1,68 @@ +createMock(AccommodationPriceRepository::class)); + + $covered = $coverage->coveredDatesFor( + [$this->price('2026-06-01', '2026-06-09')], + new \DateTimeImmutable('2026-06-01'), + new \DateTimeImmutable('2026-06-12'), + ); + + // dateTo is the last night, so 2026-06-09 is still covered + self::assertSame([ + '2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', '2026-06-05', + '2026-06-06', '2026-06-07', '2026-06-08', '2026-06-09', + ], array_keys($covered)); + } + + public function testGapBetweenPricePeriodsStaysUncovered(): void + { + $coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)); + + $covered = $coverage->coveredDatesFor( + [$this->price('2026-06-01', '2026-06-02'), $this->price('2026-06-05', '2026-06-06')], + new \DateTimeImmutable('2026-06-01'), + new \DateTimeImmutable('2026-06-06'), + ); + + self::assertSame( + ['2026-06-01', '2026-06-02', '2026-06-05', '2026-06-06'], + array_keys($covered), + ); + } + + public function testNoPricesMeansNoCoverage(): void + { + $coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)); + + $covered = $coverage->coveredDatesFor( + [], + new \DateTimeImmutable('2026-06-01'), + new \DateTimeImmutable('2026-06-03'), + ); + + self::assertSame([], $covered); + } + + private function price(string $from, string $to): AccommodationPrice + { + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable($from)); + $price->setDateTo(new \DateTimeImmutable($to)); + + return $price; + } +}