Files
myep/src/Service/GroupsPriceCalculator.php
T
2026-09-11 19:37:00 +02:00

317 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 (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 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,
];
}
}