190 lines
6.9 KiB
PHP
190 lines
6.9 KiB
PHP
<?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.
|
|
*
|
|
* 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.
|
|
*/
|
|
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 (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(
|
|
'<info>%s</info>: %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);
|
|
}
|
|
}
|