feat: improved contingents api performance with db backed snapshots
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user