fix: treat dates without price as unavailable
This commit is contained in:
@@ -6,12 +6,14 @@ namespace App\Controller\Api;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentPricesQuery;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -34,6 +36,7 @@ class ContingentController extends AbstractController
|
||||
private readonly AccommodationPriceRepository $priceRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -96,8 +99,10 @@ class ContingentController extends AbstractController
|
||||
|
||||
$currency = $accommodation->getCurrency();
|
||||
|
||||
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
||||
|
||||
$data = array_map(
|
||||
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency),
|
||||
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $covered, $currency),
|
||||
$calendar->data,
|
||||
);
|
||||
|
||||
@@ -106,13 +111,19 @@ class ContingentController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array<string, true> $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, string $currency): array
|
||||
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,
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
@@ -38,6 +39,7 @@ class Step1Controller extends AbstractAccommodationController
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly CalendarGridBuilder $calendarGridBuilder,
|
||||
private readonly GroupsPriceCalculator $priceCalculator,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -225,9 +227,12 @@ class Step1Controller extends AbstractAccommodationController
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches contingent + price data and returns a map of date → ['status', 'minNights'].
|
||||
* Fetches contingent + price data and returns a map of date → ['status', 'minNights']
|
||||
* covering every day of the requested range.
|
||||
*
|
||||
* Returns an empty array on API failure; the template treats missing dates as blocked.
|
||||
* A day is only available when the contingent allows it *and* an AccommodationPrice
|
||||
* covers it. On API failure the contingent imposes no restriction and price coverage
|
||||
* alone decides.
|
||||
*
|
||||
* @return array<string, array{status: string, minNights: int}>
|
||||
*/
|
||||
@@ -238,6 +243,7 @@ class Step1Controller extends AbstractAccommodationController
|
||||
string $dateFromStr,
|
||||
string $dateToStr,
|
||||
): array {
|
||||
$calendar = null;
|
||||
try {
|
||||
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
|
||||
$calendar = $this->cache->get(
|
||||
@@ -249,23 +255,38 @@ class Step1Controller extends AbstractAccommodationController
|
||||
},
|
||||
);
|
||||
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
|
||||
return [];
|
||||
// Fall through with $calendar === null — price coverage still applies
|
||||
}
|
||||
|
||||
$contingentStatus = [];
|
||||
if (null !== $calendar) {
|
||||
foreach ($calendar->data as $entry) {
|
||||
$contingentStatus[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = $entry->status;
|
||||
}
|
||||
}
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
||||
|
||||
$start = $dateFrom->setTime(0, 0);
|
||||
$end = $dateTo->setTime(0, 0);
|
||||
|
||||
$availableDates = [];
|
||||
foreach ($calendar->data as $entry) {
|
||||
if (ContingentStatus::Ok === $entry->status) {
|
||||
$availableDates[$entry->date] = true;
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
$date = $day->format('Y-m-d');
|
||||
// Days the contingent API does not mention are unrestricted
|
||||
$status = $contingentStatus[$date] ?? ContingentStatus::Ok;
|
||||
|
||||
if (ContingentStatus::Ok === $status && isset($covered[$date])) {
|
||||
$availableDates[$date] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$enriched = [];
|
||||
foreach ($calendar->data as $entry) {
|
||||
$isAvailable = ContingentStatus::Ok === $entry->status;
|
||||
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
|
||||
$prevAvailable = isset($availableDates[$prevDate]);
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
$date = $day->format('Y-m-d');
|
||||
$isAvailable = isset($availableDates[$date]);
|
||||
$prevAvailable = isset($availableDates[$day->modify('-1 day')->format('Y-m-d')]);
|
||||
|
||||
$status = match (true) {
|
||||
$isAvailable && $prevAvailable => 'ok',
|
||||
@@ -274,14 +295,13 @@ class Step1Controller extends AbstractAccommodationController
|
||||
default => 'blocked',
|
||||
};
|
||||
|
||||
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
|
||||
$candidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
||||
fn (AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
||||
));
|
||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
||||
|
||||
$enriched[$entry->date] = [
|
||||
$enriched[$date] = [
|
||||
'status' => $status,
|
||||
'minNights' => $winner?->getMinNights() ?? 0,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
|
||||
/**
|
||||
* Determines which days of a date range are covered by an AccommodationPrice.
|
||||
*
|
||||
* A day without a covering price is not sold and must not be offered as available,
|
||||
* regardless of what the external contingent calendar reports.
|
||||
*/
|
||||
final readonly class AccommodationPriceCoverage
|
||||
{
|
||||
public function __construct(
|
||||
private AccommodationPriceRepository $priceRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> keyed by Y-m-d, only covered days present
|
||||
*/
|
||||
public function coveredDates(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||
{
|
||||
return $this->coveredDatesFor(
|
||||
$this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo),
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
*
|
||||
* @return array<string, true> keyed by Y-m-d, only covered days present
|
||||
*/
|
||||
public function coveredDatesFor(array $prices, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||
{
|
||||
$start = $dateFrom->setTime(0, 0);
|
||||
$end = $dateTo->setTime(0, 0);
|
||||
|
||||
$covered = [];
|
||||
|
||||
// dateFrom and dateTo are both inclusive (last night, not checkout day)
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
foreach ($prices as $price) {
|
||||
if ($price->getDateFrom() <= $day && $price->getDateTo() >= $day) {
|
||||
$covered[$day->format('Y-m-d')] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $covered;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Model\CalendarDay;
|
||||
use App\Model\CalendarMonth;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
@@ -45,6 +46,7 @@ class Calendar
|
||||
public function __construct(
|
||||
private readonly ContingentsClient $contingentsClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -119,6 +121,18 @@ class Calendar
|
||||
}
|
||||
}
|
||||
|
||||
// A day without a price is not sold, no matter what the contingent says.
|
||||
$rangeStart = new \DateTimeImmutable($dateFrom);
|
||||
$rangeEnd = new \DateTimeImmutable($dateTo);
|
||||
$covered = $this->priceCoverage->coveredDates($hotelCode, $rangeStart, $rangeEnd);
|
||||
|
||||
for ($day = $rangeStart->setTime(0, 0); $day <= $rangeEnd->setTime(0, 0); $day = $day->modify('+1 day')) {
|
||||
$key = $day->format('Y-m-d');
|
||||
if (!isset($covered[$key])) {
|
||||
$blocked[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user