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); } }