From 80d734e7d63b29841057a76148f85a7ee0bbcbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 26 Aug 2026 13:32:25 +0200 Subject: [PATCH] feat: derive calendar date range from price configurations --- docs/api-consumer-guide.md | 16 +- src/Controller/Api/ContingentController.php | 27 +++- src/Model/ContingentCalendarQuery.php | 55 +++++-- .../Groups/AccommodationPriceRepository.php | 29 ++++ .../Api/ContingentControllerTest.php | 145 +++++++++++++++++- tests/Model/ContingentQueryTest.php | 13 ++ 6 files changed, 260 insertions(+), 25 deletions(-) diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 6c442cf..523a37c 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -180,16 +180,24 @@ Prices are **decimal major units** (converted from integer minor units server-si #### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api` -Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. +Per-day availability enriched with prices. + +`dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. + +**Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries. + +Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one. + +The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended. Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale. -The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint now returns **one entry per requested day**. Previously days the upstream service did not mention were simply absent, so a response may now contain days it would not have contained before. It is a superset, never a different shape. +The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape. Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent: - **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges) -- **days beyond the synced horizon** — currently 24 months out +- **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet. Each entry: @@ -208,7 +216,7 @@ Each entry: } ``` -Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. +Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes. Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters. diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 72754f3..716e8ad 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -72,9 +72,6 @@ class ContingentController extends AbstractController #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ContingentCalendarQuery $query, ): JsonResponse { - $dateFrom = $query->dateFromDate(); - $dateTo = $query->dateToDate(); - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { @@ -83,6 +80,30 @@ class ContingentController extends AbstractController return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } + $dateFrom = $query->dateFromDate(); + $dateTo = $query->dateToDate(); + + // Without an explicit range the persisted prices define one: what we have priced is what we sell. + if (null === $dateFrom || null === $dateTo) { + $bounds = $this->priceRepository->findPricedDateBoundsByHotelCode($query->hotelCode); + + // Nothing priced means nothing sold, so there is no calendar to serve. This returns before + // the snapshot is consulted, and deliberately so: the 502 below exists to avoid passing + // unknown availability off as "all blocked", and here there is nothing to be unsure about. + if (null === $bounds) { + return $this->json([]); + } + + // Past date ranges are of no use to API consumers: never start the range before today. + $dateFrom = max($bounds['from'], CarbonImmutable::now()->setTime(0, 0)); + $dateTo = $bounds['to']; + + // Every priced period has already ended. + if ($dateFrom > $dateTo) { + return $this->json([]); + } + } + // An empty or stale snapshot must not be served as though every day were blocked: that // is a plausible-looking 200 nobody can distinguish from real data. Fail the way this // endpoint always failed instead, so existing consumers need no change. diff --git a/src/Model/ContingentCalendarQuery.php b/src/Model/ContingentCalendarQuery.php index bdf0529..b51004d 100644 --- a/src/Model/ContingentCalendarQuery.php +++ b/src/Model/ContingentCalendarQuery.php @@ -17,21 +17,40 @@ final readonly class ContingentCalendarQuery #[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')] public string $hotelCode, - #[Assert\NotBlank] #[Assert\Date] - public string $dateFrom, + public ?string $dateFrom = null, - #[Assert\NotBlank] #[Assert\Date] - public string $dateTo, + public ?string $dateTo = null, ) { } #[Assert\Callback] public function validateRange(ExecutionContextInterface $context): void { - $dateFrom = self::parseDate($this->dateFrom); - $dateTo = self::parseDate($this->dateTo); + $from = $this->dateFrom; + $to = $this->dateTo; + + $hasFrom = null !== $from && '' !== $from; + $hasTo = null !== $to && '' !== $to; + + // Neither supplied: the caller wants the full priced span, which the persisted prices define. + if (!$hasFrom && !$hasTo) { + return; + } + + // A lone dateFrom would have to mean "from there to the end of the prices", a third mode + // nobody asked for. Demand the pair instead of inventing a meaning for half of it. + if (!$hasFrom || !$hasTo) { + $context->buildViolation('dateFrom and dateTo must be supplied together.') + ->atPath($hasFrom ? 'dateTo' : 'dateFrom') + ->addViolation(); + + return; + } + + $dateFrom = self::parseDate($from); + $dateTo = self::parseDate($to); if (null === $dateFrom || null === $dateTo) { return; @@ -52,15 +71,29 @@ final readonly class ContingentCalendarQuery } } - public function dateFromDate(): \DateTimeImmutable + /** + * Null when the parameter was not supplied, which puts the request into full-span mode. + */ + public function dateFromDate(): ?\DateTimeImmutable { - return self::parseDate($this->dateFrom) - ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + return $this->suppliedDate($this->dateFrom); } - public function dateToDate(): \DateTimeImmutable + /** + * Null when the parameter was not supplied, which puts the request into full-span mode. + */ + public function dateToDate(): ?\DateTimeImmutable { - return self::parseDate($this->dateTo) + return $this->suppliedDate($this->dateTo); + } + + private function suppliedDate(?string $value): ?\DateTimeImmutable + { + if (null === $value || '' === $value) { + return null; + } + + return self::parseDate($value) ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); } diff --git a/src/Repository/Groups/AccommodationPriceRepository.php b/src/Repository/Groups/AccommodationPriceRepository.php index 97c878a..a0c23d9 100644 --- a/src/Repository/Groups/AccommodationPriceRepository.php +++ b/src/Repository/Groups/AccommodationPriceRepository.php @@ -41,4 +41,33 @@ class AccommodationPriceRepository extends ServiceEntityRepository ->getQuery() ->getResult(); } + + /** + * The outer bounds of every persisted price period for the accommodation, or null when it has none. + * + * @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null + */ + public function findPricedDateBoundsByHotelCode(string $hotelCode): ?array + { + /** @var array{minFrom: string|null, maxTo: string|null} $bounds */ + $bounds = $this->createQueryBuilder('ap') + ->select('MIN(ap.dateFrom) AS minFrom', 'MAX(ap.dateTo) AS maxTo') + ->join('ap.accommodation', 'a') + ->where('a.calendarCode = :hotelCode') + ->setParameter('hotelCode', $hotelCode) + ->getQuery() + ->getSingleResult(); + + // An aggregate over no rows still returns one row, with both bounds null. + if (null === $bounds['minFrom'] || null === $bounds['maxTo']) { + return null; + } + + // Both columns are DATE_IMMUTABLE, so the aggregates come back as Y-m-d and land at 00:00 - + // the same normalisation the day-by-day comparisons downstream expect. + return [ + 'from' => new \DateTimeImmutable($bounds['minFrom']), + 'to' => new \DateTimeImmutable($bounds['maxTo']), + ]; + } } diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php index b8ea31c..8d61893 100644 --- a/tests/Controller/Api/ContingentControllerTest.php +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -14,6 +14,7 @@ use App\Repository\Groups\AccommodationRepository; use App\Service\AccommodationPriceCoverage; use App\Service\ContingentSnapshotReader; use App\Service\PriceTimelineBuilder; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Container; @@ -21,6 +22,11 @@ use Symfony\Component\HttpFoundation\Response; class ContingentControllerTest extends TestCase { + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + } + public function testEnrichEntryIncludesPricingMetadata(): void { $controller = $this->createController(); @@ -84,20 +90,145 @@ class ContingentControllerTest extends TestCase self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status')); } + public function testCalendarWithoutARangeCoversTheWholePricedSpan(): void + { + CarbonImmutable::setTestNow('2026-06-01 09:00:00'); + + $data = $this->callFullSpanCalendar( + ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], + [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], + ); + + // Every day between the bounds, gaps included: consumers key off status, so a day that is + // simply absent has no status at all and cannot be told apart from one we never mentioned. + self::assertSame('2026-07-01', $data[0]['date']); + self::assertSame('2026-08-31', $data[array_key_last($data)]['date']); + self::assertCount(62, $data); + } + + public function testCalendarWithoutARangeIsContiguous(): void + { + CarbonImmutable::setTestNow('2026-06-01 09:00:00'); + + $dates = array_column($this->callFullSpanCalendar( + ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], + [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], + ), 'date'); + + $expected = []; + for ($day = new \DateTimeImmutable('2026-07-01'); $day <= new \DateTimeImmutable('2026-08-31'); $day = $day->modify('+1 day')) { + $expected[] = $day->format('Y-m-d'); + } + + self::assertSame($expected, $dates); + } + + public function testCalendarWithoutARangeBlocksDaysNoPriceCovers(): void + { + CarbonImmutable::setTestNow('2026-06-01 09:00:00'); + + $data = $this->callFullSpanCalendar( + ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], + [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], + ['2026-07-31' => ContingentStatus::Ok, '2026-08-01' => ContingentStatus::Ok], + ); + + $byDate = array_column($data, null, 'date'); + + // The closure between the two periods keeps a status, and it is BLOCKED even though the + // snapshot reports the day as free: no price means not sold. + self::assertSame('OK', $byDate['2026-07-31']['status']); + self::assertSame('BLOCKED', $byDate['2026-08-01']['status']); + self::assertNull($byDate['2026-08-01']['pricePerNight']); + self::assertSame('BLOCKED', $byDate['2026-08-09']['status']); + } + + public function testCalendarWithoutARangeNeverStartsBeforeToday(): void + { + CarbonImmutable::setTestNow('2026-07-15 09:00:00'); + + $data = $this->callFullSpanCalendar( + ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-07-31')], + [$this->createPrice()], + ); + + self::assertSame('2026-07-15', $data[0]['date']); + self::assertCount(17, $data); + } + + public function testCalendarWithoutARangeIsEmptyWhenTheHotelHasNoPrices(): void + { + self::assertSame([], $this->callFullSpanCalendar(null, [])); + } + + public function testCalendarWithoutARangeIsEmptyWhenEveryPricedPeriodHasPassed(): void + { + CarbonImmutable::setTestNow('2026-09-01 09:00:00'); + + $data = $this->callFullSpanCalendar( + ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-07-31')], + [$this->createPrice()], + ); + + self::assertSame([], $data); + } + + public function testExplicitRangeNeverConsultsThePricedBounds(): void + { + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->expects(self::never())->method('findPricedDateBoundsByHotelCode'); + + $this->callCalendar([], null, null, $priceRepository); + } + + /** + * @param array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds + * @param AccommodationPrice[] $prices + * @param array $statuses + * + * @return array> + */ + private function callFullSpanCalendar(?array $bounds, array $prices, array $statuses = []): array + { + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->method('findPricedDateBoundsByHotelCode')->willReturn($bounds); + $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); + + $response = $this->callCalendar( + $statuses, + new ContingentCalendarQuery('HOTEL1'), + $prices, + $priceRepository, + ); + + return json_decode((string) $response->getContent(), true); + } + /** * @param array|null $statuses + * @param AccommodationPrice[]|null $prices */ - private function callCalendar(?array $statuses): Response - { + private function callCalendar( + ?array $statuses, + ?ContingentCalendarQuery $query = null, + ?array $prices = null, + ?AccommodationPriceRepository $priceRepository = null, + ): Response { $accommodationRepository = $this->createMock(AccommodationRepository::class); $accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR')); $snapshotReader = $this->createMock(ContingentSnapshotReader::class); $snapshotReader->method('statusesFor')->willReturn($statuses); + $priceRepository ??= $this->createMock(AccommodationPriceRepository::class); + + if (null !== $prices) { + $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); + } + $controller = new ContingentController( $accommodationRepository, - $this->createMock(AccommodationPriceRepository::class), + $priceRepository, $snapshotReader, new PriceTimelineBuilder(), new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)), @@ -108,7 +239,7 @@ class ContingentControllerTest extends TestCase // JsonResponse — which is what this endpoint produces in production anyway. $controller->setContainer(new Container()); - return $controller->calendar(new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03')); + return $controller->calendar($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03')); } private function createController(): ContingentController @@ -123,11 +254,11 @@ class ContingentControllerTest extends TestCase ); } - private function createPrice(): AccommodationPrice + private function createPrice(string $dateFrom = '2026-07-01', string $dateTo = '2026-07-31'): AccommodationPrice { $price = new AccommodationPrice(); - $price->setDateFrom(new \DateTimeImmutable('2026-07-01')); - $price->setDateTo(new \DateTimeImmutable('2026-07-31')); + $price->setDateFrom(new \DateTimeImmutable($dateFrom)); + $price->setDateTo(new \DateTimeImmutable($dateTo)); $price->setIncludedPax(4); $price->setPricePerNight(12345); $price->setPriceAdditionalPerson(1500); diff --git a/tests/Model/ContingentQueryTest.php b/tests/Model/ContingentQueryTest.php index 4995fb0..8be3049 100644 --- a/tests/Model/ContingentQueryTest.php +++ b/tests/Model/ContingentQueryTest.php @@ -19,6 +19,17 @@ class ContingentQueryTest extends TestCase self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d')); } + public function testCalendarQueryAcceptsAnOmittedRange(): void + { + $query = new ContingentCalendarQuery('HOTEL_1'); + + self::assertCount(0, $this->validator()->validate($query)); + + // Null is what puts the endpoint into full-span mode; there is no separate predicate for it. + self::assertNull($query->dateFromDate()); + self::assertNull($query->dateToDate()); + } + /** * @dataProvider invalidCalendarQueries */ @@ -33,6 +44,8 @@ class ContingentQueryTest extends TestCase yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')]; yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')]; yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')]; + yield 'dateFrom without dateTo' => [new ContingentCalendarQuery('HOTEL', '2026-01-01')]; + yield 'dateTo without dateFrom' => [new ContingentCalendarQuery('HOTEL', null, '2026-01-02')]; } public function testPricesQueryRequiresFourDigitYear(): void