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