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('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( '%s: %s (+%d ~%d -%d, %d beyond the previous horizon)', (string) $accommodation->getCalendarCode(), $result->changed ? 'changed' : 'unchanged', $result->added, $result->updated, $result->removed, $result->extended, ), 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 during the // day, 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); } }