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\ContingentsClient;
|
||||||
use App\BpnConnect\Exception\BpnConnectException;
|
use App\BpnConnect\Exception\BpnConnectException;
|
||||||
|
use App\BpnConnect\Model\ContingentStatus;
|
||||||
use App\Entity\Groups\AccommodationPrice;
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
use App\Enum\Groups\PriceType;
|
use App\Enum\Groups\PriceType;
|
||||||
use App\Model\ContingentCalendarQuery;
|
use App\Model\ContingentCalendarQuery;
|
||||||
use App\Model\ContingentPricesQuery;
|
use App\Model\ContingentPricesQuery;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Repository\Groups\AccommodationRepository;
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\PriceTimelineBuilder;
|
use App\Service\PriceTimelineBuilder;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -34,6 +36,7 @@ class ContingentController extends AbstractController
|
|||||||
private readonly AccommodationPriceRepository $priceRepository,
|
private readonly AccommodationPriceRepository $priceRepository,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||||
|
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -96,8 +99,10 @@ class ContingentController extends AbstractController
|
|||||||
|
|
||||||
$currency = $accommodation->getCurrency();
|
$currency = $accommodation->getCurrency();
|
||||||
|
|
||||||
|
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
||||||
|
|
||||||
$data = array_map(
|
$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,
|
$calendar->data,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,13 +111,19 @@ class ContingentController extends AbstractController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param AccommodationPrice[] $prices
|
* @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}
|
* @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);
|
$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)
|
// dateFrom and dateTo are both inclusive (last night, not checkout day)
|
||||||
$candidates = array_filter(
|
$candidates = array_filter(
|
||||||
$prices,
|
$prices,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Htmx\HxTrait;
|
|||||||
use App\Model\AccommodationBookingContext;
|
use App\Model\AccommodationBookingContext;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\AccommodationSessionManager;
|
use App\Service\AccommodationSessionManager;
|
||||||
use App\Service\CalendarGridBuilder;
|
use App\Service\CalendarGridBuilder;
|
||||||
use App\Service\GroupsPriceCalculator;
|
use App\Service\GroupsPriceCalculator;
|
||||||
@@ -38,6 +39,7 @@ class Step1Controller extends AbstractAccommodationController
|
|||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly CalendarGridBuilder $calendarGridBuilder,
|
private readonly CalendarGridBuilder $calendarGridBuilder,
|
||||||
private readonly GroupsPriceCalculator $priceCalculator,
|
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}>
|
* @return array<string, array{status: string, minNights: int}>
|
||||||
*/
|
*/
|
||||||
@@ -238,6 +243,7 @@ class Step1Controller extends AbstractAccommodationController
|
|||||||
string $dateFromStr,
|
string $dateFromStr,
|
||||||
string $dateToStr,
|
string $dateToStr,
|
||||||
): array {
|
): array {
|
||||||
|
$calendar = null;
|
||||||
try {
|
try {
|
||||||
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
|
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
|
||||||
$calendar = $this->cache->get(
|
$calendar = $this->cache->get(
|
||||||
@@ -249,23 +255,38 @@ class Step1Controller extends AbstractAccommodationController
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
|
} 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);
|
$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 = [];
|
$availableDates = [];
|
||||||
foreach ($calendar->data as $entry) {
|
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||||
if (ContingentStatus::Ok === $entry->status) {
|
$date = $day->format('Y-m-d');
|
||||||
$availableDates[$entry->date] = true;
|
// 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 = [];
|
$enriched = [];
|
||||||
foreach ($calendar->data as $entry) {
|
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||||
$isAvailable = ContingentStatus::Ok === $entry->status;
|
$date = $day->format('Y-m-d');
|
||||||
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
|
$isAvailable = isset($availableDates[$date]);
|
||||||
$prevAvailable = isset($availableDates[$prevDate]);
|
$prevAvailable = isset($availableDates[$day->modify('-1 day')->format('Y-m-d')]);
|
||||||
|
|
||||||
$status = match (true) {
|
$status = match (true) {
|
||||||
$isAvailable && $prevAvailable => 'ok',
|
$isAvailable && $prevAvailable => 'ok',
|
||||||
@@ -274,14 +295,13 @@ class Step1Controller extends AbstractAccommodationController
|
|||||||
default => 'blocked',
|
default => 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
|
|
||||||
$candidates = array_values(array_filter(
|
$candidates = array_values(array_filter(
|
||||||
$prices,
|
$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);
|
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
||||||
|
|
||||||
$enriched[$entry->date] = [
|
$enriched[$date] = [
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'minNights' => $winner?->getMinNights() ?? 0,
|
'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\Entity\Groups\Accommodation;
|
||||||
use App\Model\CalendarDay;
|
use App\Model\CalendarDay;
|
||||||
use App\Model\CalendarMonth;
|
use App\Model\CalendarMonth;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||||
@@ -45,6 +46,7 @@ class Calendar
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ContingentsClient $contingentsClient,
|
private readonly ContingentsClient $contingentsClient,
|
||||||
private readonly CacheInterface $cache,
|
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;
|
return $blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Entity\Groups\Accommodation;
|
|||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\AccommodationSessionManager;
|
use App\Service\AccommodationSessionManager;
|
||||||
use App\Service\CalendarGridBuilder;
|
use App\Service\CalendarGridBuilder;
|
||||||
use App\Service\GroupsPriceCalculator;
|
use App\Service\GroupsPriceCalculator;
|
||||||
@@ -105,15 +106,18 @@ class Step1ControllerTest extends TestCase
|
|||||||
AccommodationBookingService $bookingService,
|
AccommodationBookingService $bookingService,
|
||||||
AccommodationSessionManager $sessionManager,
|
AccommodationSessionManager $sessionManager,
|
||||||
): TestableAccommodationStep1Controller {
|
): TestableAccommodationStep1Controller {
|
||||||
|
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||||
|
|
||||||
return new TestableAccommodationStep1Controller(
|
return new TestableAccommodationStep1Controller(
|
||||||
$bookingService,
|
$bookingService,
|
||||||
$sessionManager,
|
$sessionManager,
|
||||||
$this->createMock(ContingentsClient::class),
|
$this->createMock(ContingentsClient::class),
|
||||||
$this->createMock(AccommodationPriceRepository::class),
|
$priceRepository,
|
||||||
new PriceTimelineBuilder(),
|
new PriceTimelineBuilder(),
|
||||||
$this->createMock(CacheInterface::class),
|
$this->createMock(CacheInterface::class),
|
||||||
new CalendarGridBuilder(),
|
new CalendarGridBuilder(),
|
||||||
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
|
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
|
||||||
|
new AccommodationPriceCoverage($priceRepository),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +133,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
|||||||
CacheInterface $cache,
|
CacheInterface $cache,
|
||||||
CalendarGridBuilder $calendarGridBuilder,
|
CalendarGridBuilder $calendarGridBuilder,
|
||||||
GroupsPriceCalculator $priceCalculator,
|
GroupsPriceCalculator $priceCalculator,
|
||||||
|
AccommodationPriceCoverage $priceCoverage,
|
||||||
) {
|
) {
|
||||||
parent::__construct(
|
parent::__construct(
|
||||||
$bookingService,
|
$bookingService,
|
||||||
@@ -139,6 +144,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
|||||||
$cache,
|
$cache,
|
||||||
$calendarGridBuilder,
|
$calendarGridBuilder,
|
||||||
$priceCalculator,
|
$priceCalculator,
|
||||||
|
$priceCoverage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,22 +9,56 @@ use App\Controller\Api\ContingentController;
|
|||||||
use App\Entity\Groups\AccommodationPrice;
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
use App\Repository\Groups\AccommodationPriceRepository;
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
use App\Repository\Groups\AccommodationRepository;
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\PriceTimelineBuilder;
|
use App\Service\PriceTimelineBuilder;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
class ContingentControllerTest extends TestCase
|
class ContingentControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testEnrichEntryIncludesPricingMetadata(): void
|
public function testEnrichEntryIncludesPricingMetadata(): void
|
||||||
{
|
{
|
||||||
$controller = new ContingentController(
|
$controller = $this->createController();
|
||||||
|
$price = $this->createPrice();
|
||||||
|
|
||||||
|
$result = $this->enrichEntry($controller, '2026-07-06', 'available', [$price], ['2026-07-06' => true]);
|
||||||
|
|
||||||
|
self::assertSame('available', $result['status']);
|
||||||
|
self::assertSame(4, $result['includedPax']);
|
||||||
|
self::assertSame(3, $result['minNights']);
|
||||||
|
self::assertSame(123.45, $result['pricePerNight']);
|
||||||
|
self::assertSame('EUR', $result['currency']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnrichEntryBlocksDayWithoutPrice(): void
|
||||||
|
{
|
||||||
|
$controller = $this->createController();
|
||||||
|
$price = $this->createPrice();
|
||||||
|
|
||||||
|
$result = $this->enrichEntry($controller, '2026-08-01', 'OK', [$price], ['2026-07-06' => true]);
|
||||||
|
|
||||||
|
self::assertSame('BLOCKED', $result['status']);
|
||||||
|
self::assertNull($result['pricePerNight']);
|
||||||
|
self::assertNull($result['includedPax']);
|
||||||
|
self::assertNull($result['minNights']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createController(): ContingentController
|
||||||
|
{
|
||||||
|
return new ContingentController(
|
||||||
$this->createMock(ContingentsClient::class),
|
$this->createMock(ContingentsClient::class),
|
||||||
$this->createMock(AccommodationRepository::class),
|
$this->createMock(AccommodationRepository::class),
|
||||||
$this->createMock(AccommodationPriceRepository::class),
|
$this->createMock(AccommodationPriceRepository::class),
|
||||||
$this->createMock(CacheInterface::class),
|
$this->createMock(CacheInterface::class),
|
||||||
new PriceTimelineBuilder(),
|
new PriceTimelineBuilder(),
|
||||||
|
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
||||||
|
$this->createMock(LoggerInterface::class),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPrice(): AccommodationPrice
|
||||||
|
{
|
||||||
$price = new AccommodationPrice();
|
$price = new AccommodationPrice();
|
||||||
$price->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
$price->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
||||||
$price->setDateTo(new \DateTimeImmutable('2026-07-31'));
|
$price->setDateTo(new \DateTimeImmutable('2026-07-31'));
|
||||||
@@ -33,13 +67,24 @@ class ContingentControllerTest extends TestCase
|
|||||||
$price->setPriceAdditionalPerson(1500);
|
$price->setPriceAdditionalPerson(1500);
|
||||||
$price->setMinNights(3);
|
$price->setMinNights(3);
|
||||||
|
|
||||||
|
return $price;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AccommodationPrice[] $prices
|
||||||
|
* @param array<string, true> $covered
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function enrichEntry(
|
||||||
|
ContingentController $controller,
|
||||||
|
string $date,
|
||||||
|
string $status,
|
||||||
|
array $prices,
|
||||||
|
array $covered,
|
||||||
|
): array {
|
||||||
$method = new \ReflectionMethod($controller, 'enrichEntry');
|
$method = new \ReflectionMethod($controller, 'enrichEntry');
|
||||||
|
|
||||||
$result = $method->invoke($controller, '2026-07-06', 'available', [$price], 'EUR');
|
return $method->invoke($controller, $date, $status, $prices, $covered, 'EUR');
|
||||||
|
|
||||||
self::assertSame(4, $result['includedPax']);
|
|
||||||
self::assertSame(3, $result['minNights']);
|
|
||||||
self::assertSame(123.45, $result['pricePerNight']);
|
|
||||||
self::assertSame('EUR', $result['currency']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Groups;
|
||||||
|
|
||||||
|
use App\BpnConnect\ContingentsClient;
|
||||||
|
use App\BpnConnect\Model\ContingentCalendarEntry;
|
||||||
|
use App\BpnConnect\Model\ContingentCalendarMeta;
|
||||||
|
use App\BpnConnect\Model\ContingentCalendarResponse;
|
||||||
|
use App\BpnConnect\Model\ContingentStatus;
|
||||||
|
use App\Controller\Groups\Step1Controller;
|
||||||
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
|
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;
|
||||||
|
use App\Service\PriceTimelineBuilder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
class Step1CalendarDataTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDaysWithoutPriceAreBlockedEvenWhenContingentIsOk(): void
|
||||||
|
{
|
||||||
|
// Contingent says OK for the whole week, prices only cover 03.–05.
|
||||||
|
$price = new AccommodationPrice();
|
||||||
|
$price->setDateFrom(new \DateTimeImmutable('2026-06-03'));
|
||||||
|
$price->setDateTo(new \DateTimeImmutable('2026-06-05'));
|
||||||
|
$price->setMinNights(2);
|
||||||
|
|
||||||
|
$enriched = $this->buildEnrichedDayData(
|
||||||
|
$this->calendarResponse([
|
||||||
|
'2026-06-01' => ContingentStatus::Ok,
|
||||||
|
'2026-06-02' => ContingentStatus::Ok,
|
||||||
|
'2026-06-03' => ContingentStatus::Ok,
|
||||||
|
'2026-06-04' => ContingentStatus::Ok,
|
||||||
|
'2026-06-05' => ContingentStatus::Ok,
|
||||||
|
'2026-06-06' => ContingentStatus::Ok,
|
||||||
|
'2026-06-07' => ContingentStatus::Ok,
|
||||||
|
]),
|
||||||
|
[$price],
|
||||||
|
'2026-06-01',
|
||||||
|
'2026-06-07',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('blocked', $enriched['2026-06-01']['status']);
|
||||||
|
self::assertSame('blocked', $enriched['2026-06-02']['status']);
|
||||||
|
// First priced night — arrival only
|
||||||
|
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||||
|
self::assertSame('ok', $enriched['2026-06-04']['status']);
|
||||||
|
self::assertSame('ok', $enriched['2026-06-05']['status']);
|
||||||
|
// Day after the last priced night — still valid as checkout
|
||||||
|
self::assertSame('checkout-only', $enriched['2026-06-06']['status']);
|
||||||
|
self::assertSame('blocked', $enriched['2026-06-07']['status']);
|
||||||
|
|
||||||
|
self::assertSame(2, $enriched['2026-06-04']['minNights']);
|
||||||
|
self::assertSame(0, $enriched['2026-06-07']['minNights']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlockedContingentWinsOverExistingPrice(): void
|
||||||
|
{
|
||||||
|
$price = new AccommodationPrice();
|
||||||
|
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||||
|
$price->setDateTo(new \DateTimeImmutable('2026-06-03'));
|
||||||
|
$price->setMinNights(1);
|
||||||
|
|
||||||
|
$enriched = $this->buildEnrichedDayData(
|
||||||
|
$this->calendarResponse([
|
||||||
|
'2026-06-01' => ContingentStatus::Ok,
|
||||||
|
'2026-06-02' => ContingentStatus::Blocked,
|
||||||
|
'2026-06-03' => ContingentStatus::Ok,
|
||||||
|
]),
|
||||||
|
[$price],
|
||||||
|
'2026-06-01',
|
||||||
|
'2026-06-03',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||||
|
self::assertSame('checkout-only', $enriched['2026-06-02']['status']);
|
||||||
|
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void
|
||||||
|
{
|
||||||
|
$price = new AccommodationPrice();
|
||||||
|
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||||
|
$price->setDateTo(new \DateTimeImmutable('2026-06-02'));
|
||||||
|
$price->setMinNights(1);
|
||||||
|
|
||||||
|
$enriched = $this->buildEnrichedDayData(null, [$price], '2026-06-01', '2026-06-03');
|
||||||
|
|
||||||
|
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||||
|
self::assertSame('ok', $enriched['2026-06-02']['status']);
|
||||||
|
self::assertSame('checkout-only', $enriched['2026-06-03']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, ContingentStatus> $statuses
|
||||||
|
*/
|
||||||
|
private function calendarResponse(array $statuses): ContingentCalendarResponse
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
foreach ($statuses as $date => $status) {
|
||||||
|
$entries[] = new ContingentCalendarEntry(date: $date, status: $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContingentCalendarResponse(
|
||||||
|
new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)),
|
||||||
|
$entries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AccommodationPrice[] $prices
|
||||||
|
*
|
||||||
|
* @return array<string, array{status: string, minNights: int}>
|
||||||
|
*/
|
||||||
|
private function buildEnrichedDayData(
|
||||||
|
?ContingentCalendarResponse $calendar,
|
||||||
|
array $prices,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
): array {
|
||||||
|
$cache = $this->createMock(CacheInterface::class);
|
||||||
|
if (null === $calendar) {
|
||||||
|
$cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down'));
|
||||||
|
} else {
|
||||||
|
$cache->method('get')->willReturn($calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||||
|
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
|
||||||
|
|
||||||
|
$controller = new Step1Controller(
|
||||||
|
$this->createMock(AccommodationBookingService::class),
|
||||||
|
$this->createMock(AccommodationSessionManager::class),
|
||||||
|
$this->createMock(ContingentsClient::class),
|
||||||
|
$priceRepository,
|
||||||
|
new PriceTimelineBuilder(),
|
||||||
|
$cache,
|
||||||
|
$this->createMock(CalendarGridBuilder::class),
|
||||||
|
$this->createMock(GroupsPriceCalculator::class),
|
||||||
|
new AccommodationPriceCoverage($priceRepository),
|
||||||
|
);
|
||||||
|
|
||||||
|
$method = new \ReflectionMethod($controller, 'buildEnrichedDayData');
|
||||||
|
|
||||||
|
return $method->invoke(
|
||||||
|
$controller,
|
||||||
|
'HOTEL',
|
||||||
|
new \DateTimeImmutable($dateFrom),
|
||||||
|
new \DateTimeImmutable($dateTo),
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationPrice;
|
||||||
|
use App\Repository\Groups\AccommodationPriceRepository;
|
||||||
|
use App\Service\AccommodationPriceCoverage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AccommodationPriceCoverageTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCoversOnlyDaysWithinPricePeriod(): void
|
||||||
|
{
|
||||||
|
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||||
|
|
||||||
|
$covered = $coverage->coveredDatesFor(
|
||||||
|
[$this->price('2026-06-01', '2026-06-09')],
|
||||||
|
new \DateTimeImmutable('2026-06-01'),
|
||||||
|
new \DateTimeImmutable('2026-06-12'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// dateTo is the last night, so 2026-06-09 is still covered
|
||||||
|
self::assertSame([
|
||||||
|
'2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', '2026-06-05',
|
||||||
|
'2026-06-06', '2026-06-07', '2026-06-08', '2026-06-09',
|
||||||
|
], array_keys($covered));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGapBetweenPricePeriodsStaysUncovered(): void
|
||||||
|
{
|
||||||
|
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||||
|
|
||||||
|
$covered = $coverage->coveredDatesFor(
|
||||||
|
[$this->price('2026-06-01', '2026-06-02'), $this->price('2026-06-05', '2026-06-06')],
|
||||||
|
new \DateTimeImmutable('2026-06-01'),
|
||||||
|
new \DateTimeImmutable('2026-06-06'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['2026-06-01', '2026-06-02', '2026-06-05', '2026-06-06'],
|
||||||
|
array_keys($covered),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoPricesMeansNoCoverage(): void
|
||||||
|
{
|
||||||
|
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||||
|
|
||||||
|
$covered = $coverage->coveredDatesFor(
|
||||||
|
[],
|
||||||
|
new \DateTimeImmutable('2026-06-01'),
|
||||||
|
new \DateTimeImmutable('2026-06-03'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $covered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function price(string $from, string $to): AccommodationPrice
|
||||||
|
{
|
||||||
|
$price = new AccommodationPrice();
|
||||||
|
$price->setDateFrom(new \DateTimeImmutable($from));
|
||||||
|
$price->setDateTo(new \DateTimeImmutable($to));
|
||||||
|
|
||||||
|
return $price;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user