Files
myep/src/Service/PriceTimelineBuilder.php
T
2026-09-11 19:37:00 +02:00

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;
}
}