feat: groups price calculator admin crud, booking/offer flow and api
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
<?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 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
|
||||
*
|
||||
* @return list<PriceTimelineItem>
|
||||
*/
|
||||
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) {
|
||||
$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: $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user