accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { $this->logger->error('Hotel not found for hotelcode '.$query->hotelCode); return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } $yearStart = new \DateTimeImmutable("{$query->year}-01-01"); $yearEnd = new \DateTimeImmutable("{$query->year}-12-31"); // Past date ranges are of no use to API consumers: never start the timeline before today. $rangeStart = max($yearStart, CarbonImmutable::now()->setTime(0, 0)); if ($rangeStart > $yearEnd) { return $this->json([]); } $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $rangeStart, $yearEnd); $currency = $accommodation->getCurrency(); return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $rangeStart, $yearEnd, $currency)); } #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] public function calendar( #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ContingentCalendarQuery $query, ): JsonResponse { $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { $this->logger->error('Hotel not found for hotelcode '.$query->hotelCode); return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } $dateFrom = $query->dateFromDate(); $dateTo = $query->dateToDate(); // Without an explicit range the persisted prices define one: what we have priced is what we sell. if (null === $dateFrom || null === $dateTo) { $bounds = $this->priceRepository->findPricedDateBoundsByHotelCode($query->hotelCode); // Nothing priced means nothing sold, so there is no calendar to serve. This returns before // the snapshot is consulted, and deliberately so: the 502 below exists to avoid passing // unknown availability off as "all blocked", and here there is nothing to be unsure about. if (null === $bounds) { return $this->json([]); } // Past date ranges are of no use to API consumers: never start the range before today. $dateFrom = max($bounds['from'], CarbonImmutable::now()->setTime(0, 0)); $dateTo = $bounds['to']; // Every priced period has already ended. if ($dateFrom > $dateTo) { return $this->json([]); } } // An empty or stale snapshot must not be served as though every day were blocked: that // is a plausible-looking 200 nobody can distinguish from real data. Fail the way this // endpoint always failed instead, so existing consumers need no change. $statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo); if (null === $statuses) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); } $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo); $currency = $accommodation->getCurrency(); $covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo); $data = []; for ($day = $dateFrom; $day <= $dateTo; $day = $day->modify('+1 day')) { $date = $day->format('Y-m-d'); // Days the snapshot does not know about are not sold, same rule as days without a price. $status = $statuses[$date] ?? ContingentStatus::Blocked; $data[] = $this->enrichEntry($date, $status->value, $prices, $covered, $currency); } return $this->json($data); } /** * The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are * left out rather than blocked, so a portal shows them as free like it did with the feed * that read bpn-connect directly. * * Serves every synced hotel code, including TYPO3's calendar hotels that have no * accommodation (winter-only hotels). */ #[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])] public function availability( #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ContingentCalendarQuery $query, ): JsonResponse { if (null === $this->hotelDirectory->find($query->hotelCode)) { $this->logger->error('Hotel not found for hotelcode '.$query->hotelCode); return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } // Without a range: from today to wherever the snapshot ends. $dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0); $dateTo = $query->dateToDate(); // Same reasoning as calendar(): a missing or stale snapshot must not pass for real data. // Here it matters even more, since an empty feed would show every day as free. $statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo); if (null === $statuses) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); } ksort($statuses); $data = []; foreach ($statuses as $date => $status) { $data[] = ['date' => (string) $date, 'status' => $status->value]; } return $this->json($data); } /** * One row per synced hotel code, for the overview of which hotel supports which calendar. * * `available` is whether `availability` currently serves the hotel; calendar and price table * additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show. */ #[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])] public function status(): JsonResponse { $states = $this->syncStateRepository->findAllIndexedByHotelCode(); $data = []; foreach ($this->hotelDirectory->all() as $hotel) { $state = $states[$hotel->code] ?? null; $accommodation = $hotel->accommodation; $bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null; $data[] = [ 'hotelCode' => $hotel->code, 'name' => $hotel->calendarHotel?->getName(), 'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()), 'accommodation' => null !== $accommodation ? [ 'name' => $accommodation->getName(), 'cmsCode' => $accommodation->getEffectiveCmsCode(), ] : null, 'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null, 'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null, 'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM), 'lastError' => $state?->getLastError(), 'failureCount' => $state?->getFailureCount() ?? 0, 'available' => $this->snapshotReader->isCurrent($state), ]; } return $this->json($data); } /** * @param AccommodationPrice[] $prices * @param array $covered * * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null} */ private function enrichEntry(string $date, string $status, array $prices, array $covered, string $currency): array { $day = (new \DateTimeImmutable($date))->setTime(0, 0); // A day without a price is not sold, no matter what the contingent says. if (!isset($covered[$day->format('Y-m-d')])) { $status = ContingentStatus::Blocked->value; } // dateFrom and dateTo are both inclusive (last night, not checkout day) $candidates = array_filter( $prices, fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, ); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); $defaultPrice = null; if (PriceType::DISCOUNT === $winner?->getType()) { $defaults = array_filter($candidates, fn ($p) => null === $p->getType()); $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); } return [ 'date' => $date, 'status' => $status, 'type' => $winner?->getType()?->value, 'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null, 'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null, 'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, 'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, 'currency' => $currency, 'includedPax' => $winner?->getIncludedPax(), 'minNights' => $winner?->getMinNights(), ]; } }