fix: exclude past dates in price tables
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Repository\Groups\AccommodationPriceRepository;
|
|||||||
use App\Repository\Groups\AccommodationRepository;
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
use App\Service\AccommodationPriceCoverage;
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\PriceTimelineBuilder;
|
use App\Service\PriceTimelineBuilder;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -57,11 +58,18 @@ class ContingentController extends AbstractController
|
|||||||
$yearStart = new \DateTimeImmutable("{$query->year}-01-01");
|
$yearStart = new \DateTimeImmutable("{$query->year}-01-01");
|
||||||
$yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
|
$yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
|
||||||
|
|
||||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd);
|
// 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();
|
$currency = $accommodation->getCurrency();
|
||||||
|
|
||||||
return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency));
|
return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $rangeStart, $yearEnd, $currency));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
|
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ use App\Model\PriceTimelineItem;
|
|||||||
class PriceTimelineBuilder
|
class PriceTimelineBuilder
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Builds a flat, non-overlapping timeline of effective price rows for the given year.
|
* Builds a flat, non-overlapping timeline of effective price rows for the given date range.
|
||||||
*
|
*
|
||||||
* Base price periods are split wherever an override or discount applies, so each
|
* Base price periods are split wherever an override or discount applies, so each
|
||||||
* returned row represents a contiguous date range with a single effective price.
|
* returned row represents a contiguous date range with a single effective price.
|
||||||
* Periods with no price configured are omitted. Prices that start before or end after
|
* Periods with no price configured are omitted. Prices that start before or end after
|
||||||
* the queried year are clamped to its boundaries.
|
* the queried range are clamped to its boundaries.
|
||||||
*
|
*
|
||||||
* The algorithm is an interval sweep:
|
* The algorithm is an interval sweep:
|
||||||
* 1. Collect every dateFrom and (dateTo + 1 day) from all prices as boundary points,
|
* 1. Collect every dateFrom and (dateTo + 1 day) from all prices as boundary points,
|
||||||
* plus the year start and (yearEnd + 1 day). This ensures the sweep slices at
|
* plus the range start and (rangeEnd + 1 day). This ensures the sweep slices at
|
||||||
* every point where the set of active prices changes.
|
* every point where the set of active prices changes.
|
||||||
* 2. Walk adjacent boundary pairs [start, end). For each segment, find all prices
|
* 2. Walk adjacent boundary pairs [start, end). For each segment, find all prices
|
||||||
* active at `start` and resolve the winner via resolveWinner(), which prefers
|
* active at `start` and resolve the winner via resolveWinner(), which prefers
|
||||||
@@ -39,20 +39,20 @@ class PriceTimelineBuilder
|
|||||||
*/
|
*/
|
||||||
public function buildTimeline(
|
public function buildTimeline(
|
||||||
array $prices,
|
array $prices,
|
||||||
\DateTimeImmutable $yearStart,
|
\DateTimeImmutable $rangeStart,
|
||||||
\DateTimeImmutable $yearEnd,
|
\DateTimeImmutable $rangeEnd,
|
||||||
string $currency,
|
string $currency,
|
||||||
): array {
|
): array {
|
||||||
if (empty($prices)) {
|
if (empty($prices)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$yearEndNext = $yearEnd->modify('+1 day');
|
$rangeEndNext = $rangeEnd->modify('+1 day');
|
||||||
|
|
||||||
// Step 1: collect all boundary points, keyed by timestamp for deduplication.
|
// Step 1: collect all boundary points, keyed by timestamp for deduplication.
|
||||||
$boundaries = [
|
$boundaries = [
|
||||||
$yearStart->getTimestamp() => $yearStart,
|
$rangeStart->getTimestamp() => $rangeStart,
|
||||||
$yearEndNext->getTimestamp() => $yearEndNext,
|
$rangeEndNext->getTimestamp() => $rangeEndNext,
|
||||||
];
|
];
|
||||||
foreach ($prices as $price) {
|
foreach ($prices as $price) {
|
||||||
$from = $price->getDateFrom();
|
$from = $price->getDateFrom();
|
||||||
@@ -73,9 +73,9 @@ class PriceTimelineBuilder
|
|||||||
$segStart = $boundaries[$i];
|
$segStart = $boundaries[$i];
|
||||||
$segEndNext = $boundaries[$i + 1];
|
$segEndNext = $boundaries[$i + 1];
|
||||||
|
|
||||||
// Boundaries from prices outside the year are included to correctly detect
|
// Boundaries from prices outside the range are included to correctly detect
|
||||||
// overlaps at the year edges, but the segments themselves are skipped.
|
// overlaps at the range edges, but the segments themselves are skipped.
|
||||||
if ($segStart < $yearStart || $segStart >= $yearEndNext) {
|
if ($segStart < $rangeStart || $segStart >= $rangeEndNext) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ class PriceTimelineBuilderTest extends TestCase
|
|||||||
$this->assertSame('2026-12-31', $result[0]->dateTo);
|
$this->assertSame('2026-12-31', $result[0]->dateTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInProgressPriceIsClampedToMidYearRangeStart(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice('2026-06-01', '2026-09-30', pricePerNight: 8000);
|
||||||
|
|
||||||
|
$result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-08-06'), new \DateTimeImmutable('2026-12-31'), 'EUR');
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertSame('2026-08-06', $result[0]->dateFrom);
|
||||||
|
$this->assertSame('2026-09-30', $result[0]->dateTo);
|
||||||
|
$this->assertSame(80.0, $result[0]->pricePerNight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPriceEndingBeforeRangeStartIsOmitted(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice('2026-02-01', '2026-05-31', pricePerNight: 8000);
|
||||||
|
|
||||||
|
$result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-08-06'), new \DateTimeImmutable('2026-12-31'), 'EUR');
|
||||||
|
|
||||||
|
$this->assertSame([], $result);
|
||||||
|
}
|
||||||
|
|
||||||
public function testDiscountSplitsBasePeriodIntoThreeRows(): void
|
public function testDiscountSplitsBasePeriodIntoThreeRows(): void
|
||||||
{
|
{
|
||||||
$base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000);
|
$base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000);
|
||||||
|
|||||||
Reference in New Issue
Block a user