feat: sync contingents for TYPO3 calendar hotels
This commit is contained in:
1 parent
ec36b61275
commit
97c6330a9a
24 files changed
+1067
-180
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Model\CmsCalendarHotel;
|
||||
use App\Model\CmsHotelData;
|
||||
use App\Model\CmsRegionData;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -169,6 +170,40 @@ class CmsDataProvider
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a
|
||||
* usable list. Uncached: the only caller keeps its own copy of the last good answer.
|
||||
*
|
||||
* @return list<CmsCalendarHotel>|null
|
||||
*/
|
||||
public function fetchCalendarHotels(): ?array
|
||||
{
|
||||
try {
|
||||
$data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray();
|
||||
} catch (ExceptionInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_is_list($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
$code = $entry['code'] ?? null;
|
||||
|
||||
if (!is_string($code) || '' === $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $entry['name'] ?? null;
|
||||
$hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code);
|
||||
}
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
|
||||
* the calendar hotels of TYPO3's visible products.
|
||||
*
|
||||
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
|
||||
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
|
||||
* TYPO3 does not make its hotels drop out of the sync.
|
||||
*/
|
||||
class ContingentHotelDirectory
|
||||
{
|
||||
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
|
||||
// for is not worth syncing, and it would not fit the column either.
|
||||
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
|
||||
|
||||
public function __construct(
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly CalendarHotelRepository $calendarHotelRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
|
||||
*
|
||||
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
|
||||
* a fault on its side, not every winter hotel being withdrawn at once.
|
||||
*/
|
||||
public function refresh(): bool
|
||||
{
|
||||
$fetched = [];
|
||||
|
||||
foreach ($this->cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) {
|
||||
if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) {
|
||||
$this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fetched[$hotel->code] = $hotel;
|
||||
}
|
||||
|
||||
if ([] === $fetched) {
|
||||
$this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
foreach ($fetched as $code => $hotel) {
|
||||
$calendarHotel = $stored[$code] ?? null;
|
||||
|
||||
if (null === $calendarHotel) {
|
||||
$this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name));
|
||||
} elseif ($calendarHotel->getName() !== $hotel->name) {
|
||||
$calendarHotel->setName($hotel->name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_diff_key($stored, $fetched) as $calendarHotel) {
|
||||
$this->entityManager->remove($calendarHotel);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentHotel> keyed and sorted by hotel code
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$accommodations = [];
|
||||
|
||||
foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) {
|
||||
$accommodations[(string) $accommodation->getCalendarCode()] = $accommodation;
|
||||
}
|
||||
|
||||
$calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach (array_keys($accommodations + $calendarHotels) as $code) {
|
||||
$code = (string) $code;
|
||||
$hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null);
|
||||
}
|
||||
|
||||
ksort($hotels, \SORT_STRING);
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
public function find(string $hotelCode): ?ContingentHotel
|
||||
{
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
$calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]);
|
||||
|
||||
if (null === $accommodation && null === $calendarHotel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ContingentHotel($hotelCode, $accommodation, $calendarHotel);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -34,24 +33,22 @@ class ContingentSnapshotManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes [dateFrom, dateTo] for a single accommodation.
|
||||
* Refreshes [dateFrom, dateTo] for a single hotel code.
|
||||
*
|
||||
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
|
||||
* the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a
|
||||
* full-horizon run already stored.
|
||||
*/
|
||||
public function sync(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ContingentSyncResult {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
|
||||
if ('' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('No hotel code given.');
|
||||
}
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode);
|
||||
|
||||
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||
// are the window growing, not the contingent situation changing.
|
||||
@@ -67,7 +64,7 @@ class ContingentSnapshotManager
|
||||
return $this->recordFailure($state, $hotelCode, $e->getMessage());
|
||||
}
|
||||
|
||||
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
|
||||
$existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $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.
|
||||
@@ -103,7 +100,7 @@ class ContingentSnapshotManager
|
||||
|
||||
if (null === $day) {
|
||||
$day = (new ContingentDay())
|
||||
->setAccommodation($accommodation)
|
||||
->setHotelCode($hotelCode)
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -46,17 +46,15 @@ class ContingentSnapshotReader
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
public function statusesFor(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): ?array {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isUsable($accommodation, $hotelCode)) {
|
||||
if (!$this->isUsable($hotelCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,25 +67,36 @@ class ContingentSnapshotReader
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
|
||||
/**
|
||||
* Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without
|
||||
* the logging: for overviews that ask about every hotel at once.
|
||||
*/
|
||||
public function isCurrent(?ContingentSyncState $state): bool
|
||||
{
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE);
|
||||
}
|
||||
|
||||
private function isUsable(string $hotelCode): bool
|
||||
{
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode);
|
||||
|
||||
if ($this->isCurrent($state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$syncedAt = $state?->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)) {
|
||||
} else {
|
||||
$this->logger->error('Contingent snapshot is stale', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user