263 lines
11 KiB
PHP
263 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Api;
|
|
|
|
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\Repository\Groups\ContingentSyncStateRepository;
|
|
use App\Service\AccommodationPriceCoverage;
|
|
use App\Service\ContingentHotelDirectory;
|
|
use App\Service\ContingentSnapshotReader;
|
|
use App\Service\PriceTimelineBuilder;
|
|
use Carbon\CarbonImmutable;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
|
|
#[Route('/api')]
|
|
#[IsGranted('ROLE_OAUTH2_API')]
|
|
class ContingentController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private readonly AccommodationRepository $accommodationRepository,
|
|
private readonly AccommodationPriceRepository $priceRepository,
|
|
private readonly ContingentSnapshotReader $snapshotReader,
|
|
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
|
private readonly AccommodationPriceCoverage $priceCoverage,
|
|
private readonly ContingentHotelDirectory $hotelDirectory,
|
|
private readonly ContingentSyncStateRepository $syncStateRepository,
|
|
private readonly LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
#[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])]
|
|
public function prices(
|
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
|
ContingentPricesQuery $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);
|
|
}
|
|
|
|
$yearStart = new \DateTimeImmutable("{$query->year}-01-01");
|
|
$yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
|
|
|
|
// 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, $rangeStart, $yearEnd, $currency));
|
|
}
|
|
|
|
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
|
|
public function calendar(
|
|
#[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);
|
|
}
|
|
|
|
$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.
|
|
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
|
|
|
if (null === $statuses) {
|
|
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
|
}
|
|
|
|
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo);
|
|
|
|
$currency = $accommodation->getCurrency();
|
|
|
|
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
|
|
|
$data = [];
|
|
|
|
for ($day = $dateFrom; $day <= $dateTo; $day = $day->modify('+1 day')) {
|
|
$date = $day->format('Y-m-d');
|
|
|
|
// Days the snapshot does not know about are not sold, same rule as days without a price.
|
|
$status = $statuses[$date] ?? ContingentStatus::Blocked;
|
|
|
|
$data[] = $this->enrichEntry($date, $status->value, $prices, $covered, $currency);
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Serves every synced hotel code, including TYPO3's calendar hotels that have no
|
|
* accommodation (winter-only hotels).
|
|
*/
|
|
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
|
|
public function availability(
|
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
|
ContingentCalendarQuery $query,
|
|
): JsonResponse {
|
|
if (null === $this->hotelDirectory->find($query->hotelCode)) {
|
|
$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($query->hotelCode, $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);
|
|
}
|
|
|
|
/**
|
|
* One row per synced hotel code, for the overview of which hotel supports which calendar.
|
|
*
|
|
* `available` is whether `availability` currently serves the hotel; calendar and price table
|
|
* additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show.
|
|
*/
|
|
#[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])]
|
|
public function status(): JsonResponse
|
|
{
|
|
$states = $this->syncStateRepository->findAllIndexedByHotelCode();
|
|
|
|
$data = [];
|
|
|
|
foreach ($this->hotelDirectory->all() as $hotel) {
|
|
$state = $states[$hotel->code] ?? null;
|
|
$accommodation = $hotel->accommodation;
|
|
$bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null;
|
|
|
|
$data[] = [
|
|
'hotelCode' => $hotel->code,
|
|
'name' => $hotel->calendarHotel?->getName(),
|
|
'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()),
|
|
'accommodation' => null !== $accommodation ? [
|
|
'name' => $accommodation->getName(),
|
|
'cmsCode' => $accommodation->getEffectiveCmsCode(),
|
|
] : null,
|
|
'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null,
|
|
'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null,
|
|
'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM),
|
|
'lastError' => $state?->getLastError(),
|
|
'failureCount' => $state?->getFailureCount() ?? 0,
|
|
'available' => $this->snapshotReader->isCurrent($state),
|
|
];
|
|
}
|
|
|
|
return $this->json($data);
|
|
}
|
|
|
|
/**
|
|
* @param AccommodationPrice[] $prices
|
|
* @param array<string, true> $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, 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,
|
|
fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
|
);
|
|
|
|
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
|
|
|
$defaultPrice = null;
|
|
if (PriceType::DISCOUNT === $winner?->getType()) {
|
|
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
|
|
$defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults);
|
|
}
|
|
|
|
return [
|
|
'date' => $date,
|
|
'status' => $status,
|
|
'type' => $winner?->getType()?->value,
|
|
'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null,
|
|
'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null,
|
|
'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null,
|
|
'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
|
|
'currency' => $currency,
|
|
'includedPax' => $winner?->getIncludedPax(),
|
|
'minNights' => $winner?->getMinNights(),
|
|
];
|
|
}
|
|
}
|