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
+308
View File
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AdditionalServiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class GroupsPriceCalculator
{
/** @var array<int, int> short-term surcharge percentages (nights → percent) */
private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10];
/** @var array<string, int|float> */
private array $config;
/**
* @param array<string, int|float> $config
*/
public function __construct(
private readonly PriceTimelineBuilder $priceTimelineBuilder,
array $config,
) {
$this->config = $this->resolveConfig($config);
}
/**
* @param array<string, int|float> $config
* @return array<string, int|float>
*/
private function resolveConfig(array $config): array
{
$resolver = new OptionsResolver();
$resolver->setRequired(['runningCostsEur', 'runningCostsChf', 'undersubscription30Eur', 'undersubscription30Chf', 'undersubscription40Eur', 'undersubscription40Chf']);
$resolver->setAllowedTypes('runningCostsEur', ['int', 'float']);
$resolver->setAllowedTypes('runningCostsChf', ['int', 'float']);
$resolver->setAllowedTypes('undersubscription30Eur', ['int', 'float']); // per-person/night surcharge when effectivePax < 40
$resolver->setAllowedTypes('undersubscription30Chf', ['int', 'float']);
$resolver->setAllowedTypes('undersubscription40Eur', ['int', 'float']); // per-person/night surcharge when 40 ≤ effectivePax < 50
$resolver->setAllowedTypes('undersubscription40Chf', ['int', 'float']);
return $resolver->resolve($config);
}
/**
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* @param AdditionalService[] $additionalServices only the selected services
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
public function calculate(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?BoardService $boardService,
array $additionalServices,
string $currency,
): array {
return $this->doCalculate(
$paxCount,
$minorsCount,
$nights,
$dateFrom,
$dateTo,
$prices,
$boardService?->getPrice(),
array_map(
fn(AdditionalService $s) => [
'label' => $s->getLabel() ?? '',
'price' => $s->getPrice() ?? 0,
'type' => $s->getType(),
],
$additionalServices,
),
$currency,
);
}
/**
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
* instead of live catalog entities.
*
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
public function calculateFromSnapshots(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalServiceSnapshots,
string $currency,
): array {
$items = array_map(
fn(array $s) => [
'label' => $s['label'],
'price' => $s['price'],
'type' => AdditionalServiceType::tryFrom($s['type']) ?? AdditionalServiceType::Flat,
],
$additionalServiceSnapshots,
);
return $this->doCalculate(
$paxCount,
$minorsCount,
$nights,
$dateFrom,
$dateTo,
$prices,
$boardServicePricePerPersonNight,
$items,
$currency,
);
}
/**
* @param AccommodationPrice[] $prices
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $additionalItems
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
private function doCalculate(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalItems,
string $currency,
): array {
// Derive effectivePax: children 03 don't count, but never drop below includedPax
$firstCandidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
));
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
$effectivePax = max($paxCount - $minorsCount, $includedPaxFloor);
// Rules 1 & 2: per-night base price + additional persons.
$boundaryMap = [$dateFrom->format('Y-m-d') => $dateFrom, $dateTo->format('Y-m-d') => $dateTo];
foreach ($prices as $p) {
$from = $p->getDateFrom();
$toNext = $p->getDateTo()->modify('+1 day');
if ($from > $dateFrom && $from < $dateTo) {
$boundaryMap[$from->format('Y-m-d')] = $from;
}
if ($toNext > $dateFrom && $toNext < $dateTo) {
$boundaryMap[$toNext->format('Y-m-d')] = $toNext;
}
}
ksort($boundaryMap);
$sortedBoundaries = array_values($boundaryMap);
$basePrice = 0;
$additionalPersonsPrice = 0;
$lastWinner = null;
for ($i = 0, $end = count($sortedBoundaries) - 1; $i < $end; ++$i) {
$segStart = $sortedBoundaries[$i];
$segNights = $segStart->diff($sortedBoundaries[$i + 1])->days;
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
if ($winner !== null) {
$lastWinner = $winner;
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
$includedPax = $winner->getIncludedPax() ?? 0;
if ($effectivePax > $includedPax) {
$additionalPersonsPrice += ($effectivePax - $includedPax) * ($winner->getPriceAdditionalPerson() ?? 0) * $segNights;
}
}
}
// Rule 3: short-term surcharge
$shortTermSurcharge = 0;
if (isset(self::SHORT_TERM_FACTORS[$nights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100);
}
// Rule 4: undersubscription surcharge (only when board is selected)
$undersubscriptionSurcharge = 0;
$undersubscriptionThreshold = null;
if ($boardServicePricePerPersonNight !== null) {
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
if ($effectivePax < 40) {
$undersubscriptionSurcharge = $effectivePax * $surcharge30 * $nights;
$undersubscriptionThreshold = 40;
} elseif ($effectivePax < 50) {
$undersubscriptionSurcharge = $effectivePax * $surcharge40 * $nights;
$undersubscriptionThreshold = 50;
}
}
// Rule 5: board price
$boardPrice = 0;
if ($boardServicePricePerPersonNight !== null) {
$boardPrice = $boardServicePricePerPersonNight * $effectivePax * $nights;
}
// Rule 6: additional services
$serviceDetails = [];
$servicesPrice = 0;
foreach ($additionalItems as $item) {
$price = match ($item['type']) {
AdditionalServiceType::Flat => $item['price'],
AdditionalServiceType::PerPerson => $item['price'] * $effectivePax,
AdditionalServiceType::PerNight => $item['price'] * $nights,
AdditionalServiceType::PerPersonPerNight => $item['price'] * $effectivePax * $nights,
};
$servicesPrice += $price;
$serviceDetails[] = ['label' => $item['label'], 'price' => $price];
}
// Rule 7: running costs — uses full paxCount (all persons including 03 year olds)
$runningCostFactor = (int) round(('CHF' === $currency ? $this->config['runningCostsChf'] : $this->config['runningCostsEur']) * 100);
$runningCosts = $paxCount * $nights * $runningCostFactor;
$total = $basePrice + $additionalPersonsPrice + $shortTermSurcharge
+ $undersubscriptionSurcharge + $boardPrice + $servicesPrice + $runningCosts;
return [
'effectivePax' => $effectivePax,
'totalPax' => $paxCount,
'includedPax' => $includedPaxFloor,
'nights' => $nights,
'basePrice' => $basePrice,
'additionalPersonsPrice' => $additionalPersonsPrice,
'shortTermSurcharge' => $shortTermSurcharge,
'undersubscriptionSurcharge' => $undersubscriptionSurcharge,
'undersubscriptionThreshold' => $undersubscriptionThreshold,
'boardPrice' => $boardPrice,
'serviceDetails' => $serviceDetails,
'servicesPrice' => $servicesPrice,
'runningCosts' => $runningCosts,
'total' => $total,
'currency' => $currency,
];
}
}