feat: sync contingents for TYPO3 calendar hotels

This commit is contained in:
fromme committed 2026-10-29 15:05:04 +01:00
1 parent ec36b61275
commit 97c6330a9a
24 files changed
+1067 -180

No files matched your search

+35
View File
@@ -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
{
+124
View File
@@ -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);
}
}
+7 -10
View File
@@ -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);
+25 -16
View File
@@ -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;
}
}