feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
+174
View File
@@ -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;
}
}