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
@@ -58,3 +58,15 @@ when@prod:
- task: app:bpn:xml-sync
frequency: "0 20-23,0-7 * * *"
description: "Syncs BusPro XML data hourly outside peak hours"
- task: app:bpn:sync-contingents --horizon-months=3
frequency: "*/15 8-19 * * *"
description: "Syncs near-term contingent snapshots every 15 min. during peak hours"
- task: app:bpn:sync-contingents --horizon-months=3
frequency: "5 20-23,0-7 * * *"
description: "Syncs near-term contingent snapshots hourly outside peak hours"
- task: app:bpn:sync-contingents --horizon-months=24
frequency: "40 2,13 * * *"
description: "Syncs the full contingent horizon twice daily"
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260820080235 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the local contingent snapshot (contingent_day) and its sync bookkeeping (contingent_sync_state).';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contingent_day (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', status VARCHAR(20) NOT NULL, INDEX IDX_8ACC3A068F3692CD (accommodation_id), UNIQUE INDEX uniq_contingent_day_accommodation_date (accommodation_id, date), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE contingent_sync_state (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, content_hash VARCHAR(64) NOT NULL, changed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', horizon_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', last_error LONGTEXT DEFAULT NULL, failure_count INT NOT NULL, UNIQUE INDEX UNIQ_737F167C8F3692CD (accommodation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE contingent_day ADD CONSTRAINT FK_8ACC3A068F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE contingent_sync_state ADD CONSTRAINT FK_737F167C8F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contingent_day DROP FOREIGN KEY FK_8ACC3A068F3692CD');
$this->addSql('ALTER TABLE contingent_sync_state DROP FOREIGN KEY FK_737F167C8F3692CD');
$this->addSql('DROP TABLE contingent_day');
$this->addSql('DROP TABLE contingent_sync_state');
}
}
+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;
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\BpnSyncContingentsCommand;
use App\Entity\Groups\Accommodation;
use App\Model\ContingentSyncResult;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The exit code drives zenstruck's failure mail, and this task runs every 15 minutes — so what
* counts as "failed" is a deliberate decision, not an implementation detail.
*/
class BpnSyncContingentsCommandTest extends TestCase
{
public function testTotalFailureFailsTheTask(): void
{
$tester = $this->runSync(['A', 'B'], [
ContingentSyncResult::failed('upstream down'),
ContingentSyncResult::failed('upstream down'),
]);
self::assertSame(Command::FAILURE, $tester->getStatusCode());
}
public function testASingleFailingHotelDoesNotFailTheTask(): void
{
$tester = $this->runSync(['A', 'B', 'C'], [
ContingentSyncResult::synced(true, 3, 0, 0),
ContingentSyncResult::failed('unknown hotel code'),
ContingentSyncResult::synced(false, 0, 0, 0),
]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('B', $tester->getDisplay());
self::assertStringContainsString('unknown hotel code', $tester->getDisplay());
}
public function testASuccessfulRunSucceeds(): void
{
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 1, 0)]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testARunWithNoAccommodationsSucceeds(): void
{
$tester = $this->runSync([], []);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
/**
* @param string[] $hotelCodes
* @param ContingentSyncResult[] $results
*/
private function runSync(array $hotelCodes, array $results): CommandTester
{
$accommodations = array_map(
static fn (string $code) => (new Accommodation())->setCalendarCode($code),
$hotelCodes,
);
$accommodationRepository = $this->createMock(AccommodationRepository::class);
$accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations);
$manager = $this->createMock(ContingentSnapshotManager::class);
if ([] !== $results) {
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
}
$command = new BpnSyncContingentsCommand(
$accommodationRepository,
$this->createMock(ContingentSyncStateRepository::class),
$this->createMock(ContingentDayRepository::class),
$manager,
new NullLogger(),
);
$application = new Application();
$application->add($command);
$tester = new CommandTester($application->find('app:bpn:sync-contingents'));
$tester->execute(['--horizon-months' => '1']);
// LockableTrait's lock lives for the life of the process, so without releasing it every
// run after the first would exit early and these assertions would pass for the wrong reason.
$release = new \ReflectionMethod($command, 'release');
$release->invoke($command);
return $tester;
}
}
@@ -4,16 +4,20 @@ declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Model\ContingentStatus;
use App\Controller\Api\ContingentController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Model\ContingentCalendarQuery;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Service\AccommodationPriceCoverage;
use App\Service\ContingentSnapshotReader;
use App\Service\PriceTimelineBuilder;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
class ContingentControllerTest extends TestCase
{
@@ -44,13 +48,75 @@ class ContingentControllerTest extends TestCase
self::assertNull($result['minNights']);
}
public function testCalendarFailsLoudlyWhenThereIsNoUsableSnapshot(): void
{
$response = $this->callCalendar(null);
self::assertSame(Response::HTTP_BAD_GATEWAY, $response->getStatusCode());
self::assertSame('{"error":"Failed to fetch contingent data."}', $response->getContent());
}
public function testCalendarServesTheSnapshotStatuses(): void
{
$response = $this->callCalendar([
'2026-09-01' => ContingentStatus::Ok,
'2026-09-02' => ContingentStatus::OnRequest,
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$data = json_decode((string) $response->getContent(), true);
// One entry per requested day, and the format is unchanged from the upstream-backed version.
self::assertCount(3, $data);
self::assertSame(
['date', 'status', 'type', 'pricePerNight', 'defaultPricePerNight', 'priceAdditionalPerson', 'defaultPriceAdditionalPerson', 'currency', 'includedPax', 'minNights'],
array_keys($data[0]),
);
// No price covers these days, so the "not sold without a price" rule blocks them all.
self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status'));
}
public function testCalendarBlocksDaysMissingFromTheSnapshot(): void
{
$data = json_decode((string) $this->callCalendar([])->getContent(), true);
self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status'));
}
/**
* @param array<string, ContingentStatus>|null $statuses
*/
private function callCalendar(?array $statuses): Response
{
$accommodationRepository = $this->createMock(AccommodationRepository::class);
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->method('statusesFor')->willReturn($statuses);
$controller = new ContingentController(
$accommodationRepository,
$this->createMock(AccommodationPriceRepository::class),
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
$this->createMock(LoggerInterface::class),
);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse — which is what this endpoint produces in production anyway.
$controller->setContainer(new Container());
return $controller->calendar(new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
}
private function createController(): ContingentController
{
return new ContingentController(
$this->createMock(ContingentsClient::class),
$this->createMock(AccommodationRepository::class),
$this->createMock(AccommodationPriceRepository::class),
$this->createMock(CacheInterface::class),
$this->createMock(ContingentSnapshotReader::class),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
$this->createMock(LoggerInterface::class),
@@ -4,22 +4,19 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Booking;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Controller\Groups\Booking\Step1Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Repository\Groups\AccommodationPriceRepository;
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 PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\CacheInterface;
class Step1CalendarDataTest extends TestCase
{
@@ -32,7 +29,7 @@ class Step1CalendarDataTest extends TestCase
$price->setMinNights(2);
$enriched = $this->buildEnrichedDayData(
$this->calendarResponse([
[
'2026-06-01' => ContingentStatus::Ok,
'2026-06-02' => ContingentStatus::Ok,
'2026-06-03' => ContingentStatus::Ok,
@@ -40,7 +37,7 @@ class Step1CalendarDataTest extends TestCase
'2026-06-05' => ContingentStatus::Ok,
'2026-06-06' => ContingentStatus::Ok,
'2026-06-07' => ContingentStatus::Ok,
]),
],
[$price],
'2026-06-01',
'2026-06-07',
@@ -68,11 +65,11 @@ class Step1CalendarDataTest extends TestCase
$price->setMinNights(1);
$enriched = $this->buildEnrichedDayData(
$this->calendarResponse([
[
'2026-06-01' => ContingentStatus::Ok,
'2026-06-02' => ContingentStatus::Blocked,
'2026-06-03' => ContingentStatus::Ok,
]),
],
[$price],
'2026-06-01',
'2026-06-03',
@@ -83,7 +80,7 @@ class Step1CalendarDataTest extends TestCase
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
}
public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void
public function testPriceCoverageStillAppliesWhenThereIsNoUsableSnapshot(): void
{
$price = new AccommodationPrice();
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
@@ -98,38 +95,19 @@ class Step1CalendarDataTest extends TestCase
}
/**
* @param array<string, ContingentStatus> $statuses
*/
private function calendarResponse(array $statuses): ContingentCalendarResponse
{
$entries = [];
foreach ($statuses as $date => $status) {
$entries[] = new ContingentCalendarEntry(date: $date, status: $status);
}
return new ContingentCalendarResponse(
new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)),
$entries,
);
}
/**
* @param AccommodationPrice[] $prices
* @param array<string, ContingentStatus>|null $statuses null = no usable snapshot
* @param AccommodationPrice[] $prices
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
?ContingentCalendarResponse $calendar,
?array $statuses,
array $prices,
string $dateFrom,
string $dateTo,
): array {
$cache = $this->createMock(CacheInterface::class);
if (null === $calendar) {
$cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down'));
} else {
$cache->method('get')->willReturn($calendar);
}
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->method('statusesFor')->willReturn($statuses);
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
@@ -137,10 +115,9 @@ class Step1CalendarDataTest extends TestCase
$controller = new Step1Controller(
$this->createMock(AccommodationBookingService::class),
$this->createMock(AccommodationSessionManager::class),
$this->createMock(ContingentsClient::class),
$priceRepository,
new PriceTimelineBuilder(),
$cache,
$snapshotReader,
$this->createMock(CalendarGridBuilder::class),
$this->createMock(GroupsPriceCalculator::class),
new AccommodationPriceCoverage($priceRepository),
@@ -150,11 +127,10 @@ class Step1CalendarDataTest extends TestCase
return $method->invoke(
$controller,
(new Accommodation())->setCalendarCode('HOTEL'),
'HOTEL',
new \DateTimeImmutable($dateFrom),
new \DateTimeImmutable($dateTo),
$dateFrom,
$dateTo,
);
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Booking;
use App\BpnConnect\ContingentsClient;
use App\Controller\Groups\Booking\Step1Controller;
use App\Entity\Groups\Accommodation;
use App\Form\Model\AccommodationBookingDto;
@@ -13,6 +12,7 @@ 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 PHPUnit\Framework\TestCase;
@@ -21,7 +21,6 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Contracts\Cache\CacheInterface;
class Step1ControllerTest extends TestCase
{
@@ -111,10 +110,9 @@ class Step1ControllerTest extends TestCase
return new TestableAccommodationStep1Controller(
$bookingService,
$sessionManager,
$this->createMock(ContingentsClient::class),
$priceRepository,
new PriceTimelineBuilder(),
$this->createMock(CacheInterface::class),
$this->createMock(ContingentSnapshotReader::class),
new CalendarGridBuilder(),
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
new AccommodationPriceCoverage($priceRepository),
@@ -127,10 +125,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller
public function __construct(
AccommodationBookingService $bookingService,
AccommodationSessionManager $sessionManager,
ContingentsClient $contingentsClient,
AccommodationPriceRepository $priceRepository,
PriceTimelineBuilder $priceTimelineBuilder,
CacheInterface $cache,
ContingentSnapshotReader $snapshotReader,
CalendarGridBuilder $calendarGridBuilder,
GroupsPriceCalculator $priceCalculator,
AccommodationPriceCoverage $priceCoverage,
@@ -138,10 +135,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller
parent::__construct(
$bookingService,
$sessionManager,
$contingentsClient,
$priceRepository,
$priceTimelineBuilder,
$cache,
$snapshotReader,
$calendarGridBuilder,
$priceCalculator,
$priceCoverage,
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentSnapshotManagerTest extends TestCase
{
private ContingentsClient&MockObject $client;
private ContingentDayRepository&MockObject $dayRepository;
private ContingentSyncStateRepository&MockObject $syncStateRepository;
private EntityManagerInterface&MockObject $entityManager;
protected function setUp(): void
{
$this->client = $this->createMock(ContingentsClient::class);
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
}
public function testAddsMissingDaysAndMarksSnapshotChanged(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK', '2026-07-02:BLOCKED']);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
]));
$this->entityManager->expects(self::exactly(3))->method('persist');
$result = $this->sync();
self::assertTrue($result->successful);
self::assertTrue($result->changed);
self::assertSame(2, $result->added);
self::assertSame(0, $result->updated);
self::assertSame(0, $result->removed);
}
public function testUpdatesChangedStatusAndRemovesVanishedDays(): void
{
$existing = [
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
]));
$this->entityManager->expects(self::once())->method('remove');
$result = $this->sync();
self::assertSame(0, $result->added);
self::assertSame(1, $result->updated);
self::assertSame(1, $result->removed);
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
}
public function testUnchangedFingerprintDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->setContentHash(hash('sha256', '2026-07-01:OK'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
$result = $this->sync();
self::assertFalse($result->changed);
self::assertNull($state->getChangedAt());
self::assertNotNull($state->getSyncedAt());
}
public function testUpstreamFailureKeepsSnapshotAndRecordsError(): void
{
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
self::assertFalse($result->successful);
self::assertSame('upstream down', $result->error);
self::assertSame(1, $state->getFailureCount());
self::assertNull($state->getSyncedAt());
}
public function testEmptyUpstreamResponseDoesNotWipeAPopulatedRange(): void
{
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
self::assertFalse($result->successful);
self::assertSame(1, $state->getFailureCount());
}
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-08-01', ContingentStatus::Ok),
]));
$result = $this->sync();
self::assertSame(1, $result->added);
}
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
// The near-term job syncs only to 2026-07-03 but must not discard the 24-month reach.
$this->sync();
self::assertSame('2028-08-20', $state->getHorizonTo()?->format('Y-m-d'));
}
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
$this->sync();
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
}
private function sync(): \App\Model\ContingentSyncResult
{
$manager = new ContingentSnapshotManager(
$this->client,
$this->dayRepository,
$this->syncStateRepository,
$this->entityManager,
$this->createMock(LoggerInterface::class),
);
return $manager->sync(
$this->accommodation(),
new \DateTimeImmutable('2026-07-01'),
new \DateTimeImmutable('2026-07-03'),
);
}
private function accommodation(): Accommodation
{
return (new Accommodation())->setCalendarCode('HOTEL1');
}
private function day(string $date, ContingentStatus $status): ContingentDay
{
return (new ContingentDay())
->setDate(new \DateTimeImmutable($date))
->setStatus($status);
}
/**
* @param ContingentCalendarEntry[] $entries
*/
private function response(array $entries): ContingentCalendarResponse
{
return new ContingentCalendarResponse(
new ContingentCalendarMeta('2026-07-01', '2026-07-03', 'days', 'HOTEL1', 1, count($entries)),
$entries,
);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotReader;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentSnapshotReaderTest extends TestCase
{
private ContingentDayRepository&MockObject $dayRepository;
private ContingentSyncStateRepository&MockObject $syncStateRepository;
protected function setUp(): void
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
}
public function testReturnsNullWhenNothingHasEverSynced(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
}
public function testReturnsNullWhenTheSnapshotIsStale(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
}
public function testReturnsStatusesWhenRecentlySynced(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-09-01' => $this->day(ContingentStatus::Ok),
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
]);
self::assertSame([
'2026-09-01' => ContingentStatus::Ok,
'2026-09-02' => ContingentStatus::OnRequest,
], $this->read());
}
public function testStillUsableJustInsideTheStalenessWindow(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
self::assertSame([], $this->read());
}
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
{
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
self::assertNull($this->read(new Accommodation()));
}
/**
* @return array<string, ContingentStatus>|null
*/
private function read(?Accommodation $accommodation = null): ?array
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
$this->syncStateRepository,
$this->createMock(LoggerInterface::class),
);
return $reader->statusesFor(
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
new \DateTimeImmutable('2026-09-01'),
new \DateTimeImmutable('2026-09-03'),
);
}
private function state(string $syncedAgo): ContingentSyncState
{
return (new ContingentSyncState())->setSyncedAt(CarbonImmutable::now()->modify($syncedAgo)->toDateTimeImmutable());
}
private function day(ContingentStatus $status): ContingentDay
{
return (new ContingentDay())->setStatus($status);
}
}