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
+12
View File
@@ -11,6 +11,12 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
abstract class AbstractApiClient
{
/** Maximum time to wait between two bytes of the response. */
private const int IDLE_TIMEOUT_SECONDS = 10;
/** Hard ceiling on a single request, including a slow-but-alive upstream. */
private const int MAX_DURATION_SECONDS = 30;
public function __construct(
protected readonly HttpClientInterface $httpClient,
protected readonly LoggerInterface $logger,
@@ -21,6 +27,7 @@ abstract class AbstractApiClient
/**
* @param array<string, mixed> $query
*
* @return array<string, mixed>
*/
protected function request(string $path, array $query = []): array
@@ -33,6 +40,11 @@ abstract class AbstractApiClient
'X-API-KEY' => $this->apiKey,
],
'query' => $query,
// Without these a stalled upstream hangs the caller indefinitely. That matters
// most for the scheduled contingent sync, which holds a lock: one hung run would
// block every subsequent one and silently let the snapshot rot.
'timeout' => self::IDLE_TIMEOUT_SECONDS,
'max_duration' => self::MAX_DURATION_SECONDS,
]);
return $response->toArray();
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Groups\Accommodation;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotManager;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn:sync-contingents',
description: 'Refreshes the local contingent snapshots from bpn-connect.',
)]
/**
* Console entrypoint for the scheduled contingent snapshot sync.
*
* Runs at two cadences: a short horizon frequently, the full horizon a couple of times a day.
*/
class BpnSyncContingentsCommand extends Command
{
use LockableTrait;
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSnapshotManager $snapshotManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
/**
* Configures horizon, filtering and refresh-control options.
*/
protected function configure(): void
{
$this
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync', '24')
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
;
}
/**
* Executes the sync flow and the retention cleanup.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (false === $this->lock('bpn_sync_contingents')) {
$this->logger->info('Contingent sync skipped: another run is still in progress');
return Command::SUCCESS;
}
$horizonMonths = max(1, (int) $input->getOption('horizon-months'));
$staleAfterMinutes = max(0, (int) $input->getOption('stale-after'));
$force = true === $input->getOption('force');
$hotelCode = $input->getOption('hotel');
$today = CarbonImmutable::now()->setTime(0, 0);
$dateFrom = $today->toDateTimeImmutable();
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
if ([] === $accommodations) {
$io->warning('No accommodation with a calendarCode matched.');
return Command::SUCCESS;
}
$processed = 0;
$skipped = 0;
$changed = 0;
$failedCodes = [];
foreach ($accommodations as $accommodation) {
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
++$skipped;
continue;
}
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
++$processed;
if (!$result->successful) {
$failedCodes[] = (string) $accommodation->getCalendarCode();
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
continue;
}
if ($result->changed) {
++$changed;
}
$io->writeln(sprintf(
'<info>%s</info>: %s (+%d ~%d -%d)',
(string) $accommodation->getCalendarCode(),
$result->changed ? 'changed' : 'unchanged',
$result->added,
$result->updated,
$result->removed,
), OutputInterface::VERBOSITY_VERBOSE);
}
$failed = count($failedCodes);
$summary = [
'processed' => $processed,
'skipped' => $skipped,
'changed' => $changed,
'failed' => $failed,
'failedHotelCodes' => $failedCodes,
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
];
$io->success(sprintf(
'Contingent sync complete: %d processed, %d skipped, %d changed, %d failed',
$processed,
$skipped,
$changed,
$failed,
));
$this->logger->info('Contingent snapshot sync finished', $summary);
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
// Only a *total* failure is worth failing the task over: that means upstream is down or
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
// A single hotel breaking must stay quiet — this task runs every 15 minutes, so failing
// on it would mail dozens of times a day until someone silenced the task. That hotel is
// not lost track of: its own calendar serves a 502, and contingent_sync_state records
// failure_count and last_error.
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
}
/**
* @return Accommodation[]
*/
private function resolveAccommodations(?string $hotelCode): array
{
if (null === $hotelCode) {
return $this->accommodationRepository->findAllWithCalendarCode();
}
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
return null !== $accommodation ? [$accommodation] : [];
}
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
{
if (0 === $staleAfterMinutes) {
return false;
}
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
if (null === $syncedAt) {
return false;
}
return $syncedAt > CarbonImmutable::now()->subMinutes($staleAfterMinutes);
}
}
+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);
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\BpnConnect\Model\ContingentStatus;
use App\Repository\Groups\ContingentDayRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Local snapshot of a single day of contingent availability as reported by bpn-connect.
*
* Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable.
*/
#[ORM\Entity(repositoryClass: ContingentDayRepository::class)]
#[ORM\Table(name: 'contingent_day')]
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])]
class ContingentDay
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Accommodation $accommodation = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $date = null;
#[ORM\Column(length: 20, enumType: ContingentStatus::class)]
private ContingentStatus $status = ContingentStatus::Blocked;
public function getId(): ?int
{
return $this->id;
}
public function getAccommodation(): ?Accommodation
{
return $this->accommodation;
}
public function setAccommodation(?Accommodation $accommodation): self
{
$this->accommodation = $accommodation;
return $this;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function setDate(\DateTimeImmutable $date): self
{
$this->date = $date;
return $this;
}
public function getStatus(): ContingentStatus
{
return $this->status;
}
public function setStatus(ContingentStatus $status): self
{
$this->status = $status;
return $this;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Repository\Groups\ContingentSyncStateRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Bookkeeping for the scheduled contingent sync of a single accommodation.
*
* Holds the fingerprint of the stored contingent days, which is how the sync decides whether
* anything actually changed, and `changedAt` records when it last did.
*/
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
#[ORM\Table(name: 'contingent_sync_state')]
class ContingentSyncState
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne]
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')]
private ?Accommodation $accommodation = null;
/**
* sha256 over the stored contingent days, contingent status only.
*/
#[ORM\Column(length: 64)]
private string $contentHash = '';
/**
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $changedAt = null;
/**
* Last successful sync, regardless of whether anything changed.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $syncedAt = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $horizonTo = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $lastError = null;
#[ORM\Column]
private int $failureCount = 0;
public function __construct(?Accommodation $accommodation = null)
{
$this->accommodation = $accommodation;
}
public function getId(): ?int
{
return $this->id;
}
public function getAccommodation(): ?Accommodation
{
return $this->accommodation;
}
public function setAccommodation(?Accommodation $accommodation): self
{
$this->accommodation = $accommodation;
return $this;
}
public function getContentHash(): string
{
return $this->contentHash;
}
public function setContentHash(string $contentHash): self
{
$this->contentHash = $contentHash;
return $this;
}
public function getChangedAt(): ?\DateTimeImmutable
{
return $this->changedAt;
}
public function setChangedAt(?\DateTimeImmutable $changedAt): self
{
$this->changedAt = $changedAt;
return $this;
}
public function getSyncedAt(): ?\DateTimeImmutable
{
return $this->syncedAt;
}
public function setSyncedAt(?\DateTimeImmutable $syncedAt): self
{
$this->syncedAt = $syncedAt;
return $this;
}
public function getHorizonTo(): ?\DateTimeImmutable
{
return $this->horizonTo;
}
public function setHorizonTo(?\DateTimeImmutable $horizonTo): self
{
$this->horizonTo = $horizonTo;
return $this;
}
public function getLastError(): ?string
{
return $this->lastError;
}
public function setLastError(?string $lastError): self
{
$this->lastError = $lastError;
return $this;
}
public function getFailureCount(): int
{
return $this->failureCount;
}
public function setFailureCount(int $failureCount): self
{
$this->failureCount = $failureCount;
return $this;
}
public function recordFailure(string $error): self
{
$this->lastError = $error;
++$this->failureCount;
return $this;
}
/**
* Records a successful sync of a window ending at $horizonTo.
*
* The horizon only ever grows: the near-term job runs far more often than the full-horizon
* one, and must not discard the reach the latter established.
*/
public function recordSuccess(\DateTimeImmutable $syncedAt, \DateTimeImmutable $horizonTo): self
{
$this->syncedAt = $syncedAt;
$this->horizonTo = null === $this->horizonTo || $horizonTo > $this->horizonTo ? $horizonTo : $this->horizonTo;
$this->lastError = null;
$this->failureCount = 0;
return $this;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* Outcome of a single accommodation's contingent snapshot sync.
*/
readonly class ContingentSyncResult
{
private function __construct(
public bool $successful,
public bool $changed,
public int $added,
public int $updated,
public int $removed,
public ?string $error = null,
) {
}
public static function synced(bool $changed, int $added, int $updated, int $removed): self
{
return new self(true, $changed, $added, $updated, $removed);
}
public static function failed(string $error): self
{
return new self(false, false, 0, 0, 0, $error);
}
}
@@ -60,4 +60,20 @@ class AccommodationRepository extends ServiceEntityRepository
->getOneOrNullResult()
;
}
/**
* Every accommodation that is addressable by a calendar hotel code, ordered for stable output.
*
* @return Accommodation[]
*/
public function findAllWithCalendarCode(): array
{
return $this->createQueryBuilder('a')
->andWhere('a.calendarCode IS NOT NULL')
->andWhere("a.calendarCode != ''")
->orderBy('a.calendarCode', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContingentDay>
*/
class ContingentDayRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContingentDay::class);
}
/**
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
* identified by the given hotel code, keyed by Y-m-d.
*
* @return array<string, ContingentDay>
*/
public function findByHotelCodeAndDateRange(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): array {
/** @var ContingentDay[] $days */
$days = $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();
return $this->indexByDate($days);
}
/**
* Same as above but for a known accommodation; used by the sync to diff against.
*
* @return array<string, ContingentDay>
*/
public function findByAccommodationAndDateRange(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): array {
/** @var ContingentDay[] $days */
$days = $this->createQueryBuilder('cd')
->where('cd.accommodation = :accommodation')
->andWhere('cd.date >= :dateFrom')
->andWhere('cd.date <= :dateTo')
->setParameter('accommodation', $accommodation)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->orderBy('cd.date', 'ASC')
->getQuery()
->getResult();
return $this->indexByDate($days);
}
/**
* Returns the accommodation's complete stored snapshot as an ordered "Y-m-d:STATUS" list.
*
* Scalar hydration keeps the fingerprint cheap: the entities themselves are of no interest here.
*
* @return list<string>
*/
public function findStatusFingerprintParts(Accommodation $accommodation): array
{
/** @var array<int, array{date: \DateTimeImmutable, status: \App\BpnConnect\Model\ContingentStatus}> $rows */
$rows = $this->createQueryBuilder('cd')
->select('cd.date', 'cd.status')
->where('cd.accommodation = :accommodation')
->setParameter('accommodation', $accommodation)
->orderBy('cd.date', 'ASC')
->getQuery()
->getArrayResult();
return array_map(
static fn (array $row) => $row['date']->format('Y-m-d').':'.$row['status']->value,
$rows,
);
}
/**
* Retention: drops snapshot days that are in the past and can no longer be requested.
*/
public function deleteBefore(\DateTimeImmutable $date): int
{
return (int) $this->createQueryBuilder('cd')
->delete()
->where('cd.date < :date')
->setParameter('date', $date)
->getQuery()
->execute();
}
/**
* @param ContingentDay[] $days
*
* @return array<string, ContingentDay>
*/
private function indexByDate(array $days): array
{
$indexed = [];
foreach ($days as $day) {
$indexed[$day->getDate()->format('Y-m-d')] = $day;
}
return $indexed;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentSyncState;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContingentSyncState>
*/
class ContingentSyncStateRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContingentSyncState::class);
}
public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState
{
return $this->findOneBy(['accommodation' => $accommodation]);
}
/**
* Returns every sync state keyed by accommodation id, for the revisions endpoint.
*
* @return array<int, ContingentSyncState>
*/
public function findAllIndexedByAccommodationId(): array
{
$indexed = [];
foreach ($this->findAll() as $state) {
$accommodation = $state->getAccommodation();
if (null === $accommodation || null === $accommodation->getId()) {
continue;
}
$indexed[$accommodation->getId()] = $state;
}
return $indexed;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Model\ContingentSyncResult;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Keeps the local contingent snapshot in sync with bpn-connect.
*
* This is the only place that still talks to the upstream contingent API: the read path
* serves the stored snapshot, so upstream latency and upstream outages stay out of it.
*/
class ContingentSnapshotManager
{
public function __construct(
private readonly ContingentsClient $contingentsClient,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* Refreshes [dateFrom, dateTo] for a single accommodation.
*
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
* the requested window is left untouched, which is what lets the near-term and full-horizon
* schedules run at different cadences without fighting each other.
*/
public function sync(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): ContingentSyncResult {
$hotelCode = $accommodation->getCalendarCode();
if (null === $hotelCode || '' === $hotelCode) {
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
}
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
try {
$calendar = $this->contingentsClient->getContingentCalendar(
$hotelCode,
$dateFrom->format('Y-m-d'),
$dateTo->format('Y-m-d'),
);
} catch (BpnConnectException $e) {
return $this->recordFailure($state, $hotelCode, $e->getMessage());
}
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
// An empty upstream response would otherwise wipe the window and render the whole
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
if ([] === $calendar->data && [] !== $existing) {
return $this->recordFailure($state, $hotelCode, 'Upstream returned no contingent days for a range that is already populated.');
}
$added = 0;
$updated = 0;
$seen = [];
foreach ($calendar->data as $entry) {
try {
$date = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
} catch (\Exception $e) {
$this->logger->warning('Skipping contingent day with an unparsable date', [
'hotelCode' => $hotelCode,
'date' => $entry->date,
'error' => $e->getMessage(),
]);
continue;
}
if ($date < $dateFrom || $date > $dateTo) {
continue;
}
$key = $date->format('Y-m-d');
$seen[$key] = true;
$day = $existing[$key] ?? null;
if (null === $day) {
$day = (new ContingentDay())
->setAccommodation($accommodation)
->setDate($date);
$this->entityManager->persist($day);
++$added;
} elseif ($day->getStatus() === $entry->status) {
continue;
} else {
++$updated;
}
$day->setStatus($entry->status);
}
$removed = 0;
foreach ($existing as $key => $day) {
if (!isset($seen[$key])) {
$this->entityManager->remove($day);
++$removed;
}
}
$this->entityManager->flush();
$now = CarbonImmutable::now()->toDateTimeImmutable();
$hash = $this->fingerprint($accommodation);
$changed = $hash !== $state->getContentHash();
if ($changed) {
$state->setContentHash($hash)->setChangedAt($now);
}
$state->recordSuccess($now, $dateTo);
$this->entityManager->persist($state);
$this->entityManager->flush();
return ContingentSyncResult::synced($changed, $added, $updated, $removed);
}
/**
* sha256 over the accommodation's complete stored snapshot, contingent status only.
*/
public function fingerprint(Accommodation $accommodation): string
{
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
}
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
{
$this->logger->error('Contingent snapshot sync failed', [
'hotelCode' => $hotelCode,
'error' => $error,
]);
$state->recordFailure($error);
$this->entityManager->persist($state);
$this->entityManager->flush();
return ContingentSyncResult::failed($error);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;
/**
* Reads contingent availability out of the local snapshot, and says when it cannot be trusted.
*
* Callers get null rather than a silently empty result, because "no data" and "everything is
* blocked" are indistinguishable once the statuses are flattened — and the two current callers
* want opposite fallbacks: the API fails loud with a 502, the booking calendar falls back to
* price coverage alone.
*/
class ContingentSnapshotReader
{
/**
* How old the snapshot may get before we stop presenting it as current availability.
*
* The near-term sync runs every 15 minutes during the day and hourly overnight, so this only
* trips when the scheduler is genuinely broken, never on the normal cadence.
*/
private const string MAX_SNAPSHOT_AGE = '-6 hours';
public function __construct(
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly LoggerInterface $logger,
) {
}
/**
* 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.
*
* @return array<string, ContingentStatus>|null
*/
public function statusesFor(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): ?array {
$hotelCode = $accommodation->getCalendarCode();
if (null === $hotelCode || '' === $hotelCode) {
return null;
}
if (!$this->isUsable($accommodation, $hotelCode)) {
return null;
}
$statuses = [];
foreach ($this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo) as $date => $day) {
$statuses[$date] = $day->getStatus();
}
return $statuses;
}
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
{
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
if (null === $syncedAt) {
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
return false;
}
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
$this->logger->error('Contingent snapshot is stale', [
'hotelCode' => $hotelCode,
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
]);
return false;
}
return true;
}
}