160 lines
6.5 KiB
PHP
160 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\Groups\AccommodationPrice;
|
|
use App\Enum\Groups\PriceType;
|
|
|
|
class PriceTimelineBuilder
|
|
{
|
|
/**
|
|
* Builds a flat, non-overlapping timeline of effective price rows for the given year.
|
|
*
|
|
* 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 year 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 year start and (yearEnd + 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
|
|
*/
|
|
public function buildTimeline(
|
|
array $prices,
|
|
\DateTimeImmutable $yearStart,
|
|
\DateTimeImmutable $yearEnd,
|
|
string $currency,
|
|
): array {
|
|
if (empty($prices)) {
|
|
return [];
|
|
}
|
|
|
|
$yearEndNext = $yearEnd->modify('+1 day');
|
|
|
|
// Step 1: collect all boundary points, keyed by timestamp for deduplication.
|
|
$boundaries = [
|
|
$yearStart->getTimestamp() => $yearStart,
|
|
$yearEndNext->getTimestamp() => $yearEndNext,
|
|
];
|
|
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 year are included to correctly detect
|
|
// overlaps at the year edges, but the segments themselves are skipped.
|
|
if ($segStart < $yearStart || $segStart >= $yearEndNext) {
|
|
continue;
|
|
}
|
|
|
|
$candidates = array_filter(
|
|
$prices,
|
|
fn($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
|
);
|
|
|
|
$winner = $this->resolveWinner($candidates);
|
|
|
|
if ($winner === null) {
|
|
// Gap: reset merge state so the next winner always opens a new row.
|
|
$lastWinner = null;
|
|
$lastDefault = null;
|
|
continue;
|
|
}
|
|
|
|
$defaultWinner = null;
|
|
if ($winner->getType() === PriceType::DISCOUNT) {
|
|
$defaults = array_filter($candidates, fn($p) => $p->getType() === null);
|
|
$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) {
|
|
$rows[count($rows) - 1]['dateTo'] = $segDateTo->format('Y-m-d');
|
|
} else {
|
|
$rows[] = [
|
|
'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' => $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
|
'defaultPriceAdditionalPerson' => $defaultWinner !== null ? 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 ($winner === null) {
|
|
$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;
|
|
}
|
|
}
|