feat: add raw contingent availability endpoint
This commit is contained in:
1 parent
7c8dbeef48
commit
ec36b61275
6 files changed
+189
-12
No files matched your search
@@ -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.
|
`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)
|
### Operating the snapshot (deployment)
|
||||||
|
|||||||
@@ -133,6 +133,47 @@ class ContingentController extends AbstractController
|
|||||||
return $this->json($data);
|
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 AccommodationPrice[] $prices
|
||||||
* @param array<string, true> $covered
|
* @param array<string, true> $covered
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery
|
|||||||
$hasFrom = null !== $from && '' !== $from;
|
$hasFrom = null !== $from && '' !== $from;
|
||||||
$hasTo = null !== $to && '' !== $to;
|
$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) {
|
if (!$hasFrom && !$hasTo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,27 +21,31 @@ class ContingentDayRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
|
* 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>
|
* @return array<string, ContingentDay>
|
||||||
*/
|
*/
|
||||||
public function findByHotelCodeAndDateRange(
|
public function findByHotelCodeAndDateRange(
|
||||||
string $hotelCode,
|
string $hotelCode,
|
||||||
\DateTimeImmutable $dateFrom,
|
\DateTimeImmutable $dateFrom,
|
||||||
\DateTimeImmutable $dateTo,
|
?\DateTimeImmutable $dateTo,
|
||||||
): array {
|
): array {
|
||||||
/** @var ContingentDay[] $days */
|
$qb = $this->createQueryBuilder('cd')
|
||||||
$days = $this->createQueryBuilder('cd')
|
|
||||||
->join('cd.accommodation', 'a')
|
->join('cd.accommodation', 'a')
|
||||||
->where('a.calendarCode = :hotelCode')
|
->where('a.calendarCode = :hotelCode')
|
||||||
->andWhere('cd.date >= :dateFrom')
|
->andWhere('cd.date >= :dateFrom')
|
||||||
->andWhere('cd.date <= :dateTo')
|
|
||||||
->setParameter('hotelCode', $hotelCode)
|
->setParameter('hotelCode', $hotelCode)
|
||||||
->setParameter('dateFrom', $dateFrom)
|
->setParameter('dateFrom', $dateFrom)
|
||||||
->setParameter('dateTo', $dateTo)
|
->orderBy('cd.date', 'ASC');
|
||||||
->orderBy('cd.date', 'ASC')
|
|
||||||
->getQuery()
|
if (null !== $dateTo) {
|
||||||
->getResult();
|
$qb->andWhere('cd.date <= :dateTo')
|
||||||
|
->setParameter('dateTo', $dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var ContingentDay[] $days */
|
||||||
|
$days = $qb->getQuery()->getResult();
|
||||||
|
|
||||||
return $this->indexByDate($days);
|
return $this->indexByDate($days);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* 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;
|
* 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
|
* @return array<string, ContingentStatus>|null
|
||||||
*/
|
*/
|
||||||
public function statusesFor(
|
public function statusesFor(
|
||||||
Accommodation $accommodation,
|
Accommodation $accommodation,
|
||||||
\DateTimeImmutable $dateFrom,
|
\DateTimeImmutable $dateFrom,
|
||||||
\DateTimeImmutable $dateTo,
|
?\DateTimeImmutable $dateTo,
|
||||||
): ?array {
|
): ?array {
|
||||||
$hotelCode = $accommodation->getCalendarCode();
|
$hotelCode = $accommodation->getCalendarCode();
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,117 @@ class ContingentControllerTest extends TestCase
|
|||||||
$this->callCalendar([], null, null, $priceRepository);
|
$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 array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds
|
||||||
* @param AccommodationPrice[] $prices
|
* @param AccommodationPrice[] $prices
|
||||||
|
|||||||
Reference in new issue
Block a user