feat: groups price calculator admin crud, booking/offer flow and api
This commit is contained in:
@@ -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 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 ($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 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user