feat: improved contingents api performance with db backed snapshots

This commit is contained in:
Björn Fromme
2026-08-20 11:46:03 +02:00
parent d5f9f4ef10
commit 351fbab498
20 changed files with 1521 additions and 109 deletions
+17 -22
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\PriceType;
@@ -14,9 +12,9 @@ use App\Model\ContingentPricesQuery;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Service\AccommodationPriceCoverage;
use App\Service\ContingentSnapshotReader;
use App\Service\PriceTimelineBuilder;
use Carbon\CarbonImmutable;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -24,18 +22,15 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class ContingentController extends AbstractController
{
public function __construct(
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationRepository $accommodationRepository,
private readonly AccommodationPriceRepository $priceRepository,
private readonly CacheInterface $cache,
private readonly ContingentSnapshotReader $snapshotReader,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly AccommodationPriceCoverage $priceCoverage,
private readonly LoggerInterface $logger,
@@ -88,18 +83,12 @@ class ContingentController extends AbstractController
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
}
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo);
$calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo);
});
} catch (BpnConnectException|InvalidArgumentException $e) {
$this->logger->error('Failed to fetch contingent data', [
'error' => $e->getMessage(),
]);
// 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($accommodation, $dateFrom, $dateTo);
if (null === $statuses) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
}
@@ -109,10 +98,16 @@ class ContingentController extends AbstractController
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
$data = array_map(
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $covered, $currency),
$calendar->data,
);
$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);
}
@@ -4,9 +4,8 @@ declare(strict_types=1);
namespace App\Controller\Groups\Booking;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Exception\AccommodationSessionNotFoundException;
use App\Htmx\HxTrait;
@@ -16,13 +15,12 @@ use App\Service\AccommodationBookingService;
use App\Service\AccommodationPriceCoverage;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\ContingentSnapshotReader;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class Step1Controller extends AbstractController
{
@@ -33,10 +31,9 @@ class Step1Controller extends AbstractController
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationPriceRepository $priceRepository,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly CacheInterface $cache,
private readonly ContingentSnapshotReader $snapshotReader,
private readonly CalendarGridBuilder $calendarGridBuilder,
private readonly GroupsPriceCalculator $priceCalculator,
private readonly AccommodationPriceCoverage $priceCoverage,
@@ -90,7 +87,7 @@ class Step1Controller extends AbstractController
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
if (null !== $dto->dateFrom && null !== $dto->dateTo) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
@@ -149,7 +146,7 @@ class Step1Controller extends AbstractController
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
if (null !== $dto->dateFrom && null !== $dto->dateTo) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
@@ -199,7 +196,7 @@ class Step1Controller extends AbstractController
$offset = max(0, min($request->query->getInt('offset'), $maxOffset));
} else {
$offset = 0;
if ($dto->dateFrom !== null) {
if (null !== $dto->dateFrom) {
$monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12
+ ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n'));
$offset = max(0, min($monthsDiff, $maxOffset));
@@ -210,11 +207,10 @@ class Step1Controller extends AbstractController
$months = $this->calendarGridBuilder->buildMonths($displayFrom, 2);
$enrichedByDate = $this->buildEnrichedDayData(
$accommodation,
$hotelCode,
$calendarStart,
$calendarEnd,
$calendarStart->format('Y-m-d'),
$calendarEnd->format('Y-m-d'),
);
return $this->render('groups/booking/_price_calendar_grid.html.twig', [
@@ -227,43 +223,25 @@ class Step1Controller extends AbstractController
}
/**
* Fetches contingent + price data and returns a map of date → ['status', 'minNights']
* Reads contingent + price data and returns a map of date → ['status', 'minNights']
* covering every day of the requested range.
*
* A day is only available when the contingent allows it *and* an AccommodationPrice
* covers it. On API failure the contingent imposes no restriction and price coverage
* alone decides.
* covers it. Availability comes from the local snapshot; when there is no usable snapshot
* the contingent imposes no restriction and price coverage alone decides, which is how this
* calendar has always behaved when the contingent data was unavailable.
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
?Accommodation $accommodation,
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
string $dateFromStr,
string $dateToStr,
): array {
$calendar = null;
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
$calendar = $this->cache->get(
$cacheKey,
function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr);
},
);
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
// Fall through with $calendar === null — price coverage still applies
}
$contingentStatus = [];
if (null !== $calendar) {
foreach ($calendar->data as $entry) {
$contingentStatus[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = $entry->status;
}
}
$contingentStatus = null !== $accommodation
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
: [];
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);