From b82e017f73134389d0ead3675945c6471fc2bf25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 6 Aug 2026 12:19:30 +0200 Subject: [PATCH] fix: exclude past dates in price tables --- src/Controller/Api/ContingentController.php | 12 +++++++++-- src/Service/PriceTimelineBuilder.php | 22 ++++++++++----------- tests/Service/PriceTimelineBuilderTest.php | 21 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 2499202..547693d 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -15,6 +15,7 @@ use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; use App\Service\AccommodationPriceCoverage; use App\Service\PriceTimelineBuilder; +use Carbon\CarbonImmutable; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -57,11 +58,18 @@ class ContingentController extends AbstractController $yearStart = new \DateTimeImmutable("{$query->year}-01-01"); $yearEnd = new \DateTimeImmutable("{$query->year}-12-31"); - $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd); + // Past date ranges are of no use to API consumers: never start the timeline before today. + $rangeStart = max($yearStart, CarbonImmutable::now()->setTime(0, 0)); + + if ($rangeStart > $yearEnd) { + return $this->json([]); + } + + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $rangeStart, $yearEnd); $currency = $accommodation->getCurrency(); - return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency)); + return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $rangeStart, $yearEnd, $currency)); } #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] diff --git a/src/Service/PriceTimelineBuilder.php b/src/Service/PriceTimelineBuilder.php index d2c0aad..8aca208 100644 --- a/src/Service/PriceTimelineBuilder.php +++ b/src/Service/PriceTimelineBuilder.php @@ -11,16 +11,16 @@ use App\Model\PriceTimelineItem; class PriceTimelineBuilder { /** - * Builds a flat, non-overlapping timeline of effective price rows for the given year. + * Builds a flat, non-overlapping timeline of effective price rows for the given date range. * * Base price periods are split wherever an override or discount applies, so each * returned row represents a contiguous date range with a single effective price. * Periods with no price configured are omitted. Prices that start before or end after - * the queried year are clamped to its boundaries. + * the queried range are clamped to its boundaries. * * The algorithm is an interval sweep: * 1. Collect every dateFrom and (dateTo + 1 day) from all prices as boundary points, - * plus the year start and (yearEnd + 1 day). This ensures the sweep slices at + * plus the range start and (rangeEnd + 1 day). This ensures the sweep slices at * every point where the set of active prices changes. * 2. Walk adjacent boundary pairs [start, end). For each segment, find all prices * active at `start` and resolve the winner via resolveWinner(), which prefers @@ -39,20 +39,20 @@ class PriceTimelineBuilder */ public function buildTimeline( array $prices, - \DateTimeImmutable $yearStart, - \DateTimeImmutable $yearEnd, + \DateTimeImmutable $rangeStart, + \DateTimeImmutable $rangeEnd, string $currency, ): array { if (empty($prices)) { return []; } - $yearEndNext = $yearEnd->modify('+1 day'); + $rangeEndNext = $rangeEnd->modify('+1 day'); // Step 1: collect all boundary points, keyed by timestamp for deduplication. $boundaries = [ - $yearStart->getTimestamp() => $yearStart, - $yearEndNext->getTimestamp() => $yearEndNext, + $rangeStart->getTimestamp() => $rangeStart, + $rangeEndNext->getTimestamp() => $rangeEndNext, ]; foreach ($prices as $price) { $from = $price->getDateFrom(); @@ -73,9 +73,9 @@ class PriceTimelineBuilder $segStart = $boundaries[$i]; $segEndNext = $boundaries[$i + 1]; - // Boundaries from prices outside the year are included to correctly detect - // overlaps at the year edges, but the segments themselves are skipped. - if ($segStart < $yearStart || $segStart >= $yearEndNext) { + // Boundaries from prices outside the range are included to correctly detect + // overlaps at the range edges, but the segments themselves are skipped. + if ($segStart < $rangeStart || $segStart >= $rangeEndNext) { continue; } diff --git a/tests/Service/PriceTimelineBuilderTest.php b/tests/Service/PriceTimelineBuilderTest.php index a77a4d5..4588914 100644 --- a/tests/Service/PriceTimelineBuilderTest.php +++ b/tests/Service/PriceTimelineBuilderTest.php @@ -67,6 +67,27 @@ class PriceTimelineBuilderTest extends TestCase $this->assertSame('2026-12-31', $result[0]->dateTo); } + public function testInProgressPriceIsClampedToMidYearRangeStart(): void + { + $price = $this->makePrice('2026-06-01', '2026-09-30', pricePerNight: 8000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-08-06'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-08-06', $result[0]->dateFrom); + $this->assertSame('2026-09-30', $result[0]->dateTo); + $this->assertSame(80.0, $result[0]->pricePerNight); + } + + public function testPriceEndingBeforeRangeStartIsOmitted(): void + { + $price = $this->makePrice('2026-02-01', '2026-05-31', pricePerNight: 8000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-08-06'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertSame([], $result); + } + public function testDiscountSplitsBasePeriodIntoThreeRows(): void { $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000);