Files
myep/src/Service/ContingentSnapshotManager.php
T

168 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
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;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Keeps the local contingent snapshot in sync with bpn-connect.
*
* This is the only place that still talks to the upstream contingent API: the read path
* serves the stored snapshot, so upstream latency and upstream outages stay out of it.
*/
class ContingentSnapshotManager
{
public function __construct(
private readonly ContingentsClient $contingentsClient,
private readonly ContingentDayRepository $dayRepository,
private readonly ContingentSyncStateRepository $syncStateRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* Refreshes [dateFrom, dateTo] for a single accommodation.
*
* 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,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): ContingentSyncResult {
$hotelCode = $accommodation->getCalendarCode();
if (null === $hotelCode || '' === $hotelCode) {
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
}
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
// are the window growing, not the contingent situation changing.
$previousHorizonTo = $state->getHorizonTo();
try {
$calendar = $this->contingentsClient->getContingentCalendar(
$hotelCode,
$dateFrom->format('Y-m-d'),
$dateTo->format('Y-m-d'),
);
} catch (BpnConnectException $e) {
return $this->recordFailure($state, $hotelCode, $e->getMessage());
}
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $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.
if ([] === $calendar->data && [] !== $existing) {
return $this->recordFailure($state, $hotelCode, 'Upstream returned no contingent days for a range that is already populated.');
}
$added = 0;
$extended = 0;
$updated = 0;
$seen = [];
foreach ($calendar->data as $entry) {
try {
$date = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
} catch (\Exception $e) {
$this->logger->warning('Skipping contingent day with an unparsable date', [
'hotelCode' => $hotelCode,
'date' => $entry->date,
'error' => $e->getMessage(),
]);
continue;
}
if ($date < $dateFrom || $date > $dateTo) {
continue;
}
$key = $date->format('Y-m-d');
$seen[$key] = true;
$day = $existing[$key] ?? null;
if (null === $day) {
$day = (new ContingentDay())
->setAccommodation($accommodation)
->setDate($date);
$this->entityManager->persist($day);
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
++$extended;
} else {
++$added;
}
} elseif ($day->getStatus() === $entry->status) {
continue;
} else {
++$updated;
}
$day->setStatus($entry->status);
}
$removed = 0;
foreach ($existing as $key => $day) {
if (!isset($seen[$key])) {
$this->entityManager->remove($day);
++$removed;
}
}
$this->entityManager->flush();
$now = CarbonImmutable::now()->toDateTimeImmutable();
// The diff is the change signal. A digest of the stored snapshot cannot be one: the window
// rolls forward a day at a time, so every digest would cover a different span than the one
// it is compared against and every run would report a change.
$changed = $added > 0 || $updated > 0 || $removed > 0;
if ($changed) {
$state->setChangedAt($now);
}
$state->recordSuccess($now, $dateTo);
$this->entityManager->persist($state);
$this->entityManager->flush();
return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
}
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
{
$this->logger->error('Contingent snapshot sync failed', [
'hotelCode' => $hotelCode,
'error' => $error,
]);
$state->recordFailure($error);
$this->entityManager->persist($state);
$this->entityManager->flush();
return ContingentSyncResult::failed($error);
}
}