175 lines
7.2 KiB
PHP
175 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\Groups\AccommodationPrice;
|
|
use App\Enum\Groups\PriceType;
|
|
use App\Model\PriceTimelineItem;
|
|
|
|
class PriceTimelineBuilder
|
|
{
|
|
/**
|
|
* 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
|
|
* 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
|
|
* the queried range are clamped to its boundaries.
|
|
*
|
|
* The algorithm is an interval sweep:
|
|
* 1. Collect every dateFrom and (dateTo + 1 day) from all prices as boundary points,
|
|
* plus the range start and (rangeEnd + 1 day). This ensures the sweep slices at
|
|
* every point where the set of active prices changes.
|
|
* 2. Walk adjacent boundary pairs [start, end). For each segment, find all prices
|
|
* active at `start` and resolve the winner via resolveWinner(), which prefers
|
|
* higher-priority types (DISCOUNT > OVERRIDE > base) and shorter/later periods
|
|
* on ties. When the winner is a DISCOUNT, resolveWinner() is called a second time
|
|
* on the null-type candidates to obtain the original base price, which is exposed
|
|
* as defaultPricePerNight / defaultPriceAdditionalPerson.
|
|
* 3. Consecutive segments whose winner AND default-price entity are both unchanged
|
|
* are merged into one row. Tracking the default-price entity separately prevents
|
|
* a DISCOUNT row from being incorrectly extended when the underlying base price
|
|
* changes mid-discount period. A gap (no winner) resets both trackers.
|
|
*
|
|
* @param AccommodationPrice[] $prices
|
|
*
|
|
* @return list<PriceTimelineItem>
|
|
*/
|
|
public function buildTimeline(
|
|
array $prices,
|
|
\DateTimeImmutable $rangeStart,
|
|
\DateTimeImmutable $rangeEnd,
|
|
string $currency,
|
|
): array {
|
|
if (empty($prices)) {
|
|
return [];
|
|
}
|
|
|
|
$rangeEndNext = $rangeEnd->modify('+1 day');
|
|
|
|
// Step 1: collect all boundary points, keyed by timestamp for deduplication.
|
|
$boundaries = [
|
|
$rangeStart->getTimestamp() => $rangeStart,
|
|
$rangeEndNext->getTimestamp() => $rangeEndNext,
|
|
];
|
|
foreach ($prices as $price) {
|
|
$from = $price->getDateFrom();
|
|
$toNext = $price->getDateTo()->modify('+1 day');
|
|
$boundaries[$from->getTimestamp()] = $from;
|
|
$boundaries[$toNext->getTimestamp()] = $toNext;
|
|
}
|
|
|
|
ksort($boundaries);
|
|
$boundaries = array_values($boundaries);
|
|
|
|
$rows = [];
|
|
$lastWinner = null;
|
|
$lastDefault = null;
|
|
|
|
// Step 2: walk each segment [start, end) and resolve the effective price.
|
|
for ($i = 0, $count = count($boundaries) - 1; $i < $count; ++$i) {
|
|
$segStart = $boundaries[$i];
|
|
$segEndNext = $boundaries[$i + 1];
|
|
|
|
// Boundaries from prices outside the range are included to correctly detect
|
|
// overlaps at the range edges, but the segments themselves are skipped.
|
|
if ($segStart < $rangeStart || $segStart >= $rangeEndNext) {
|
|
continue;
|
|
}
|
|
|
|
$candidates = array_filter(
|
|
$prices,
|
|
fn ($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
|
);
|
|
|
|
$winner = $this->resolveWinner($candidates);
|
|
|
|
if (null === $winner) {
|
|
// Gap: reset merge state so the next winner always opens a new row.
|
|
$lastWinner = null;
|
|
$lastDefault = null;
|
|
continue;
|
|
}
|
|
|
|
$defaultWinner = null;
|
|
if (PriceType::DISCOUNT === $winner->getType()) {
|
|
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
|
|
$defaultWinner = $this->resolveWinner($defaults);
|
|
}
|
|
|
|
$segDateTo = $segEndNext->modify('-1 day');
|
|
|
|
// Step 3: extend the previous row if both the winner and the default-price entity
|
|
// are unchanged; otherwise open a new row.
|
|
if ($lastWinner === $winner && $lastDefault === $defaultWinner) {
|
|
$lastRow = $rows[count($rows) - 1];
|
|
$rows[count($rows) - 1] = new PriceTimelineItem(
|
|
dateFrom: $lastRow->dateFrom,
|
|
dateTo: $segDateTo->format('Y-m-d'),
|
|
season: $lastRow->season,
|
|
includedPax: $lastRow->includedPax,
|
|
pricePerNight: $lastRow->pricePerNight,
|
|
priceAdditionalPerson: $lastRow->priceAdditionalPerson,
|
|
defaultPricePerNight: $lastRow->defaultPricePerNight,
|
|
defaultPriceAdditionalPerson: $lastRow->defaultPriceAdditionalPerson,
|
|
currency: $lastRow->currency,
|
|
type: $lastRow->type,
|
|
);
|
|
} else {
|
|
$rows[] = new PriceTimelineItem(
|
|
dateFrom: $segStart->format('Y-m-d'),
|
|
dateTo: $segDateTo->format('Y-m-d'),
|
|
season: $winner->getSeason()?->value,
|
|
includedPax: $winner->getIncludedPax(),
|
|
pricePerNight: round($winner->getPricePerNight() / 100, 2),
|
|
priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2),
|
|
defaultPricePerNight: null !== $defaultWinner ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
|
defaultPriceAdditionalPerson: null !== $defaultWinner ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
|
|
currency: $currency,
|
|
type: $winner->getType()?->value,
|
|
);
|
|
$lastWinner = $winner;
|
|
$lastDefault = $defaultWinner;
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Resolves which price entity wins for a set of candidates active at the same point in time.
|
|
*
|
|
* Higher-priority types win: DISCOUNT (2) > OVERRIDE (1) > base/null (0).
|
|
* Ties within the same type are broken by preferring the shorter period, then the later start date.
|
|
*
|
|
* @param AccommodationPrice[] $candidates
|
|
*/
|
|
public function resolveWinner(array $candidates): ?AccommodationPrice
|
|
{
|
|
$winner = null;
|
|
foreach ($candidates as $candidate) {
|
|
if (null === $winner) {
|
|
$winner = $candidate;
|
|
continue;
|
|
}
|
|
$cp = $candidate->getType()?->priority() ?? 0;
|
|
$wp = $winner->getType()?->priority() ?? 0;
|
|
if ($cp > $wp) {
|
|
$winner = $candidate;
|
|
continue;
|
|
}
|
|
if ($cp === $wp) {
|
|
$cs = $candidate->getDateFrom()->diff($candidate->getDateTo())->days;
|
|
$ws = $winner->getDateFrom()->diff($winner->getDateTo())->days;
|
|
if ($cs < $ws || ($cs === $ws && $candidate->getDateFrom() > $winner->getDateFrom())) {
|
|
$winner = $candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $winner;
|
|
}
|
|
}
|