feat: add raw contingent availability endpoint

This commit is contained in:
fromme committed 2026-10-29 11:19:31 +01:00
1 parent 7c8dbeef48
commit ec36b61275
6 files changed
+189 -12

No files matched your search

+21
View File
@@ -507,6 +507,27 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
Each entry, ordered by date:
```json
{"date": "2027-01-30", "status": "BLOCKED"}
```
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
Two differences from `calendar`:
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
---
### Operating the snapshot (deployment)
@@ -133,6 +133,47 @@ class ContingentController extends AbstractController
return $this->json($data);
}
/**
* The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are
* left out rather than blocked, so a portal shows them as free like it did with the feed
* that read bpn-connect directly.
*/
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
public function availability(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ContingentCalendarQuery $query,
): JsonResponse {
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
if (null === $accommodation) {
$this->logger->error('Hotel not found for hotelcode '.$query->hotelCode);
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
}
// Without a range: from today to wherever the snapshot ends.
$dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0);
$dateTo = $query->dateToDate();
// Same reasoning as calendar(): a missing or stale snapshot must not pass for real data.
// Here it matters even more, since an empty feed would show every day as free.
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
if (null === $statuses) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
}
ksort($statuses);
$data = [];
foreach ($statuses as $date => $status) {
$data[] = ['date' => (string) $date, 'status' => $status->value];
}
return $this->json($data);
}
/**
* @param AccommodationPrice[] $prices
* @param array<string, true> $covered
+1 -1
View File
@@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery
$hasFrom = null !== $from && '' !== $from;
$hasTo = null !== $to && '' !== $to;
// Neither supplied: the caller wants the full priced span, which the persisted prices define.
// Neither supplied: the caller wants the full span, which each endpoint defines for itself.
if (!$hasFrom && !$hasTo) {
return;
}
@@ -21,27 +21,31 @@ class ContingentDayRepository extends ServiceEntityRepository
/**
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
* identified by the given hotel code, keyed by Y-m-d.
* identified by the given hotel code, keyed by Y-m-d. A null dateTo means
* everything from dateFrom to the end of the snapshot.
*
* @return array<string, ContingentDay>
*/
public function findByHotelCodeAndDateRange(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
?\DateTimeImmutable $dateTo,
): array {
/** @var ContingentDay[] $days */
$days = $this->createQueryBuilder('cd')
$qb = $this->createQueryBuilder('cd')
->join('cd.accommodation', 'a')
->where('a.calendarCode = :hotelCode')
->andWhere('cd.date >= :dateFrom')
->andWhere('cd.date <= :dateTo')
->setParameter('hotelCode', $hotelCode)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->orderBy('cd.date', 'ASC')
->getQuery()
->getResult();
->orderBy('cd.date', 'ASC');
if (null !== $dateTo) {
$qb->andWhere('cd.date <= :dateTo')
->setParameter('dateTo', $dateTo);
}
/** @var ContingentDay[] $days */
$days = $qb->getQuery()->getResult();
return $this->indexByDate($days);
}
+2 -2
View File
@@ -41,14 +41,14 @@ class ContingentSnapshotReader
* Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel.
*
* Days inside the range that the snapshot does not cover are simply absent from the map;
* what that means is the caller's decision.
* what that means is the caller's decision. A null dateTo reads to the end of the snapshot.
*
* @return array<string, ContingentStatus>|null
*/
public function statusesFor(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
?\DateTimeImmutable $dateTo,
): ?array {
$hotelCode = $accommodation->getCalendarCode();
@@ -181,6 +181,117 @@ class ContingentControllerTest extends TestCase
$this->callCalendar([], null, null, $priceRepository);
}
public function testAvailabilityKeepsTheStatusOfUnpricedDays(): void
{
$data = json_decode((string) $this->callAvailability([
'2026-09-01' => ContingentStatus::Ok,
'2026-09-02' => ContingentStatus::OnRequest,
'2026-09-03' => ContingentStatus::Blocked,
])->getContent(), true);
// The opposite of testCalendarServesTheSnapshotStatuses: no price here, and nothing is blocked for it.
self::assertSame(['OK', 'ON_REQUEST', 'BLOCKED'], array_column($data, 'status'));
}
public function testAvailabilityLeavesOutDaysMissingFromTheSnapshot(): void
{
$data = json_decode((string) $this->callAvailability([
'2026-09-03' => ContingentStatus::Blocked,
'2026-09-01' => ContingentStatus::Ok,
])->getContent(), true);
self::assertSame([
['date' => '2026-09-01', 'status' => 'OK'],
['date' => '2026-09-03', 'status' => 'BLOCKED'],
], $data);
}
public function testAvailabilityFailsLoudlyWhenThereIsNoUsableSnapshot(): void
{
$response = $this->callAvailability(null);
self::assertSame(Response::HTTP_BAD_GATEWAY, $response->getStatusCode());
self::assertSame('{"error":"Failed to fetch contingent data."}', $response->getContent());
}
public function testAvailabilityRejectsAnUnknownHotel(): void
{
$accommodationRepository = $this->createStub(AccommodationRepository::class);
$accommodationRepository->method('findOneBy')->willReturn(null);
$response = $this->callAvailability([], accommodationRepository: $accommodationRepository);
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
self::assertSame('{"error":"Hotel not found for hotelCode."}', $response->getContent());
}
public function testAvailabilityWithoutARangeReadsFromTodayToTheEndOfTheSnapshot(): void
{
CarbonImmutable::setTestNow('2026-10-29 09:00:00');
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with(
self::anything(),
self::callback(fn (\DateTimeImmutable $from) => '2026-10-29 00:00:00' === $from->format('Y-m-d H:i:s')),
self::isNull(),
)
->willReturn([]);
$response = $this->callAvailability(null, new ContingentCalendarQuery('HOTEL1'), $snapshotReader);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('[]', $response->getContent());
}
public function testAvailabilityWithARangePassesItThrough(): void
{
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with(self::anything(), new \DateTimeImmutable('2026-09-01'), new \DateTimeImmutable('2026-09-03'))
->willReturn([]);
$this->callAvailability(null, snapshotReader: $snapshotReader);
}
/**
* @param array<string, ContingentStatus>|null $statuses
*/
private function callAvailability(
?array $statuses,
?ContingentCalendarQuery $query = null,
?ContingentSnapshotReader $snapshotReader = null,
?AccommodationRepository $accommodationRepository = null,
): Response {
if (null === $accommodationRepository) {
$accommodationRepository = $this->createStub(AccommodationRepository::class);
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
}
if (null === $snapshotReader) {
$snapshotReader = $this->createStub(ContingentSnapshotReader::class);
$snapshotReader->method('statusesFor')->willReturn($statuses);
}
// Prices play no part in this endpoint, so any call to their repository is a failure.
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->expects(self::never())->method(self::anything());
$controller = new ContingentController(
$accommodationRepository,
$priceRepository,
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$this->createStub(LoggerInterface::class),
);
$controller->setContainer(new Container());
return $controller->availability($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
}
/**
* @param array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds
* @param AccommodationPrice[] $prices