317 lines
12 KiB
PHP
317 lines
12 KiB
PHP
<?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 0–3 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 (null !== $winner) {
|
||
$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);
|
||
}
|
||
|
||
// Board is only billable — and only triggers the catering surcharge — when it costs
|
||
// something. A free "Selbstversorgung" board is stored as 0 (BoardService::$price is
|
||
// non-nullable), so a plain null check would charge the surcharge for it.
|
||
$paidBoardPricePerPersonNight = ($boardServicePricePerPersonNight ?? 0) > 0
|
||
? $boardServicePricePerPersonNight
|
||
: null;
|
||
|
||
// Rule 4: undersubscription surcharge (only when a paid board is selected)
|
||
$undersubscriptionSurcharge = 0;
|
||
$undersubscriptionThreshold = null;
|
||
if (null !== $paidBoardPricePerPersonNight) {
|
||
$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 (null !== $paidBoardPricePerPersonNight) {
|
||
$boardPrice = $paidBoardPricePerPersonNight * $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 0–3 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,
|
||
];
|
||
}
|
||
}
|