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

+38 -31
View File
@@ -4,10 +4,9 @@ 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\ContingentHotelDirectory;
use App\Service\ContingentSnapshotManager;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;
@@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
* to re-sync a single accommodation while debugging.
* to re-sync a single hotel code while debugging.
*
* Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus
* TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both
* sources, so the snapshot tables only ever describe hotels that are still synced.
*/
class BpnSyncContingentsCommand extends Command
{
use LockableTrait;
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
private readonly ContingentHotelDirectory $hotelDirectory,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSnapshotManager $snapshotManager,
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
{
$this
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '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('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code')
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
;
}
@@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command
$dateFrom = $today->toDateTimeImmutable();
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
$this->hotelDirectory->refresh();
// array_keys() turns a numeric code into an int.
$allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all()));
$hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes;
if ([] === $accommodations) {
$io->warning('No accommodation with a calendarCode matched.');
if ([] === $hotelCodes) {
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
return Command::SUCCESS;
}
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
$changed = 0;
$failedCodes = [];
foreach ($accommodations as $accommodation) {
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
foreach ($hotelCodes as $code) {
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
++$skipped;
continue;
}
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
++$processed;
if (!$result->successful) {
$failedCodes[] = (string) $accommodation->getCalendarCode();
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
$failedCodes[] = $code;
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
continue;
}
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
$io->writeln(sprintf(
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
(string) $accommodation->getCalendarCode(),
$code,
$result->changed ? 'changed' : 'unchanged',
$result->added,
$result->updated,
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
// Only a full run knows the whole set. A code that left both sources is not served any
// more (the availability endpoint answers 400 for it), so its snapshot is dead weight;
// should it come back, the next run rebuilds it.
if (!is_string($hotelCode)) {
$orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes);
$orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes);
if ($orphanedStates > 0) {
$this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [
'hotels' => $orphanedStates,
'days' => $orphanedDays,
]);
}
}
// 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 during the
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
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
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
{
if (0 === $staleAfterMinutes) {
return false;
}
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
if (null === $syncedAt) {
return false;