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
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationBooking;
use App\Repository\Groups\AccommodationPriceRepository;
class AccommodationBookingBreakdownCalculator
{
public function __construct(
private readonly GroupsPriceCalculator $priceCalculator,
private readonly AccommodationPriceRepository $priceRepo,
) {
}
/**
* Returns the authoritative stored snapshot, falling back to current prices only for
* bookings created before snapshot persistence was introduced.
*
* @return array<string, mixed>|null null when no accommodation or dates are set
*/
public function compute(AccommodationBooking $booking): ?array
{
if (null !== $booking->getPriceBreakdown()) {
return $booking->getPriceBreakdown();
}
return $this->computeCurrent($booking);
}
/**
* Calculates against the current catalog. Use only while creating or explicitly editing
* a booking, or as a compatibility fallback for records created before price snapshots.
*
* @return array<string, mixed>|null
*/
public function computeCurrent(AccommodationBooking $booking): ?array
{
$accommodation = $booking->getAccommodation();
$dateFrom = $booking->getDateFrom();
$dateTo = $booking->getDateTo();
if (null === $accommodation || null === $dateFrom || null === $dateTo) {
return null;
}
$prices = $this->priceRepo->findByHotelCodeAndDateRange(
$accommodation->getCalendarCode() ?? '',
$dateFrom,
$dateTo,
);
return $this->priceCalculator->calculateFromSnapshots(
$booking->getPaxCount(),
$booking->getMinorsCount(),
$booking->getNights(),
$dateFrom,
$dateTo,
$prices,
$booking->getBoardServicePrice(),
$booking->getAdditionalServices(),
$accommodation->getCurrency(),
);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AccommodationBookingLinkSigner
{
private const int LINK_TTL_DAYS = 90;
private const string TIMESTAMP_PARAM = 't';
private readonly UriSigner $uriSigner;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
#[Autowire(env: 'ACCOMMODATION_OFFER_LINK_SECRET')]
string $secret,
) {
$this->uriSigner = new UriSigner($secret);
}
public function sign(AccommodationBooking $booking): string
{
$issuedAt = $booking->getAccessLinkIssuedAt();
if (null === $issuedAt) {
throw new \LogicException('Cannot sign an access link before accessLinkIssuedAt is set.');
}
$url = $this->urlGenerator->generate(
'app_groups_booking_offer',
['uuid' => $booking->getUuid(), self::TIMESTAMP_PARAM => $issuedAt->getTimestamp()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
return $this->uriSigner->sign($url);
}
public function expiresAt(AccommodationBooking $booking): ?\DateTimeImmutable
{
return $booking->getAccessLinkIssuedAt()?->modify(sprintf('+%d days', self::LINK_TTL_DAYS));
}
public function isValidLinkRequest(Request $request, AccommodationBooking $booking): bool
{
$issuedAt = $booking->getAccessLinkIssuedAt();
if (null === $issuedAt) {
return false;
}
if (!$this->uriSigner->checkRequest($request)) {
return false;
}
$timestampParam = $request->query->get(self::TIMESTAMP_PARAM);
if (null === $timestampParam || (int) $timestampParam !== $issuedAt->getTimestamp()) {
return false;
}
$expiresAt = $this->expiresAt($booking);
return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt;
}
/**
* Marks the current session as authorized to view/act on this booking's offer.
* Called once, after a successful {@see isValidLinkRequest()} check at the
* signed-link entry point — every other route in this flow then trusts the
* session instead of re-deriving cryptographic validity from its own URL
* (which wouldn't work anyway, since the signature is bound to the exact
* URI it was generated for).
*/
public function authorizeSession(Request $request, AccommodationBooking $booking): void
{
$request->getSession()->set($this->sessionKey($booking), $booking->getAccessLinkIssuedAt()?->getTimestamp());
}
public function isSessionAuthorized(Request $request, AccommodationBooking $booking): bool
{
$issuedAt = $booking->getAccessLinkIssuedAt();
if (null === $issuedAt) {
return false;
}
if ($request->getSession()->get($this->sessionKey($booking)) !== $issuedAt->getTimestamp()) {
return false;
}
$expiresAt = $this->expiresAt($booking);
return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt;
}
private function sessionKey(AccommodationBooking $booking): string
{
return 'accommodation_offer_access_'.$booking->getUuid();
}
}
+527
View File
@@ -0,0 +1,527 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
use App\Model\InquiryStatus;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class AccommodationBookingService
{
private const int PRICING_VERSION = 1;
public function __construct(
private readonly AccommodationRepository $accommodationRepo,
private readonly AccommodationPriceRepository $priceRepo,
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly BoardServiceRepository $boardServiceRepo,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly EntityManagerInterface $entityManager,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly CmsDataProvider $cmsDataProvider,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly string $officeEmail,
) {
}
/**
* Parses query params, resolves the accommodation by calendar code, and returns a fresh DTO.
*
* @throws \InvalidArgumentException if the hotel code is not found or dates are invalid
*/
public function initFromParams(AccommodationBookingQueryParams $params): AccommodationBookingDto
{
$accommodation = $this->accommodationRepo->findOneBy(['calendarCode' => $params->hotelCode]);
if (null === $accommodation) {
throw new \InvalidArgumentException(sprintf('Unterkunft mit Code "%s" nicht gefunden.', $params->hotelCode));
}
$dto = new AccommodationBookingDto();
$dto->accommodationId = $accommodation->getId();
if (null !== $params->dateFrom && null !== $params->dateTo) {
$this->applyDates($dto, $params->dateFrom, $params->dateTo);
$dateFrom = $dto->dateFrom;
$dateTo = $dto->dateTo;
if (null !== $dateFrom && null !== $dateTo) {
$dto->paxCount = $this->resolveMinPax($params->hotelCode, $dateFrom, $dateTo);
}
}
return $dto;
}
/**
* Parses and validates raw date strings, then sets them on the DTO.
*
* @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom
*/
public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
{
$dateFrom = $this->parseDate($dateFromRaw);
$dateTo = $this->parseDate($dateToRaw);
if (null === $dateFrom || null === $dateTo) {
throw new \InvalidArgumentException('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.');
}
if ($dateTo <= $dateFrom) {
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.');
}
$dto->dateFrom = $dateFrom->setTime(0, 0, 0);
$dto->dateTo = $dateTo->setTime(0, 0, 0);
}
public function computeInitialPaxCount(Accommodation $accommodation, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): int
{
return $this->resolveMinPax($accommodation->getCalendarCode() ?? '', $dateFrom, $dateTo);
}
public function loadAccommodation(int $id): ?Accommodation
{
return $this->accommodationRepo->find($id);
}
/**
* Fetches hotel details from the CMS for display in the booking flow — name, images,
* address, descriptive text blocks, amenity icons, and the surrounding region's info
* (name, coordinates, webcam, ski area text, stats, images).
*/
public function loadHotelCmsData(Accommodation $accommodation): ?CmsHotelData
{
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
}
/**
* @return AccommodationPrice[]
*/
public function loadPrices(AccommodationBookingDto $dto, Accommodation $accommodation): array
{
return $this->priceRepo->findByHotelCodeAndDateRange(
$accommodation->getCalendarCode() ?? '',
$dto->dateFrom,
$dto->dateTo,
);
}
/**
* @return array{
* boardServices: BoardService[],
* additionalServices: AdditionalService[],
* groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[]
* }
*/
public function loadAvailableServices(AccommodationBookingDto $dto, Accommodation $accommodation): array
{
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$dto->dateFrom,
$dto->dateTo,
);
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$dto->dateFrom,
$dto->dateTo,
);
$grouped = [];
$ungrouped = [];
foreach ($additionalServices as $service) {
if (null !== $service->getSelectionGroup()) {
$grouped[$service->getSelectionGroup()][] = $service;
} else {
$ungrouped[] = $service;
}
}
return [
'boardServices' => $boardServices,
'additionalServices' => $additionalServices,
'groupedAdditionalServices' => $grouped,
'ungroupedAdditionalServices' => $ungrouped,
];
}
/**
* Determines whether the booking must be treated as non-binding and why.
*
* The effective price for the first day of the booking window is used as the authoritative
* source for minNights and includedPax rules. This mirrors how the pricing side works —
* per-day prices are iterated for cost calculation, but the booking-mode decision is
* anchored to the first night's effective price.
*
* PriceTimelineBuilder::resolveWinner() resolves the DISCOUNT > OVERRIDE > base priority.
*
* @param AccommodationPrice[] $prices
*/
public function computeInquiryStatus(AccommodationBookingDto $dto, array $prices): InquiryStatus
{
if (empty($prices)) {
return new InquiryStatus(true, ['Für den gewählten Zeitraum ist kein Preis hinterlegt.']);
}
$nights = $dto->getNights();
$firstDay = $dto->dateFrom;
$candidates = array_values(array_filter(
$prices,
fn (AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
if (null === $winner) {
return new InquiryStatus(true, ['Für den Anreisezeitpunkt ist kein Preis hinterlegt.']);
}
$reasons = [];
$adultPax = $dto->paxCount - $dto->minorsCount;
if ($adultPax < $winner->getIncludedPax() && !$winner->isAcceptUndersubscription()) {
$reasons[] = sprintf(
'Mindestpersonenzahl: %d (gebucht: %d)',
$winner->getIncludedPax(),
$adultPax,
);
}
if ($nights < $winner->getMinNights() && !$winner->isAcceptShortTerm()) {
$reasons[] = sprintf(
'Mindestaufenthalt: %d Nächte (gebucht: %d)',
$winner->getMinNights(),
$nights,
);
}
return new InquiryStatus(!empty($reasons), $reasons);
}
/**
* Persists the completed booking to the database.
*
* Board and additional services are stored as frozen snapshots — independent of
* the catalog entities so that future catalog edits do not affect existing bookings.
*
* @param AccommodationPrice[] $prices
* @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services
*/
public function persist(
AccommodationBookingDto $dto,
Accommodation $accommodation,
array $prices,
array $services,
): AccommodationBooking {
$booking = new AccommodationBooking();
$booking->setAccommodation($accommodation);
$booking->setGroupName($dto->groupName);
$booking->setDateFrom($dto->dateFrom);
$booking->setDateTo($dto->dateTo);
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
$booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry);
// Freeze board service as scalar fields (no FK)
if (null !== $dto->selectedBoardServiceId) {
foreach ($services['boardServices'] as $boardService) {
if ($boardService->getId() === $dto->selectedBoardServiceId) {
$booking->setBoardServiceLabel($boardService->getLabel());
$booking->setBoardServicePrice($boardService->getPrice());
$booking->setBoardServiceOriginalId($boardService->getId());
break;
}
}
}
// Freeze additional services as JSON snapshots (no FK to catalog)
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
foreach ($services['additionalServices'] as $additionalService) {
if (!isset($selectedIds[$additionalService->getId()])) {
continue;
}
$booking->addAdditionalServiceSnapshot(
$additionalService->getLabel(),
$additionalService->getPrice(),
$additionalService->getType(),
$additionalService->getId(),
);
}
// Personal data
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
$booking->setRemarks($dto->remarks);
$this->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking);
$this->entityManager->flush();
return $booking;
}
/**
* Persists the booking and sends the notification/confirmation emails in one go.
*
* @param AccommodationPrice[] $prices
* @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services
*/
public function finalizeBooking(
AccommodationBookingDto $dto,
Accommodation $accommodation,
array $prices,
array $services,
): AccommodationBooking {
$booking = $this->persist($dto, $accommodation, $prices, $services);
$this->issueAccessLinkForDirectBooking($booking);
$this->sendNotificationEmail($booking);
$this->sendCustomerConfirmationEmail($booking);
return $booking;
}
public function refreshPriceSnapshot(AccommodationBooking $booking): void
{
$breakdown = $this->breakdownCalculator->computeCurrent($booking);
if (null === $breakdown) {
$booking->clearPriceSnapshot();
return;
}
$accommodationBase = (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0);
$boardBase = (int) ($breakdown['boardPrice'] ?? 0);
$servicesBase = (int) ($breakdown['servicesPrice'] ?? 0);
$total = (int) ($breakdown['total'] ?? 0)
- $this->discountAmount($accommodationBase, $booking->getAccommodationDiscount())
- $this->discountAmount($boardBase, $booking->getBoardServiceDiscount())
- $this->discountAmount($servicesBase, $booking->getAdditionalServicesDiscount());
$booking->setPriceSnapshot(
$breakdown,
$total,
(string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'),
self::PRICING_VERSION,
);
}
private function discountAmount(int $base, ?int $percent): int
{
return null !== $percent ? (int) round($base * $percent / 100) : 0;
}
public function sendNotificationEmail(AccommodationBooking $booking): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $this->officeEmail,
'subject' => sprintf(
'Neue Unterkunfts%s: %s',
$booking->isInquiry() ? 'anfrage' : 'buchung',
$booking->getAccommodation()?->getName(),
),
'template' => 'email/accommodation_booking.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send accommodation booking notification email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
/**
* Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet.
* No email side effect — the confirmation email is always sent separately via
* sendCustomerConfirmationEmail(), regardless of whether a link exists.
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) {
return;
}
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
/**
* Always sends a confirmation email to the customer, whether or not an access link
* exists yet — the template renders differently depending on accessLink being present.
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $booking->getEmail(),
'subject' => $booking->isInquiry()
? 'Deine Anfrage ist bei uns eingegangen'
: 'Deine Buchung ist bestätigt',
'template' => 'email/accommodation_booking_customer.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send accommodation booking customer confirmation email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
/**
* Explicit admin action: (re)issues the access link, invalidating any previously issued
* link for this booking. No email side effect — sending is a separate, explicit admin
* action via sendCustomerConfirmationEmail().
*/
public function regenerateAccessLink(AccommodationBooking $booking): void
{
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
/**
* Transitions a booking from inquiry to booking and notifies office and customer.
* Idempotent — a no-op (including no emails) if the booking is already accepted.
*/
public function acceptBooking(AccommodationBooking $booking): void
{
if (!$booking->isInquiry()) {
return;
}
$booking->setIsInquiry(false);
$booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush();
$this->sendOfferAcceptedNotificationEmail($booking);
$this->sendOfferAcceptedCustomerEmail($booking);
}
public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $this->officeEmail,
'subject' => sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()),
'template' => 'email/offer_accepted.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send offer accepted notification email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $booking->getEmail(),
'subject' => 'Deine Buchung ist bestätigt',
'template' => 'email/offer_accepted_customer.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send offer accepted customer email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
private function accessLinkOrNull(AccommodationBooking $booking): ?string
{
return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null;
}
private function parseDate(string $value): ?\DateTimeImmutable
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = \DateTimeImmutable::getLastErrors();
if (false === $date) {
return null;
}
if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
return null;
}
if ($date->format('Y-m-d') !== $value) {
return null;
}
return $date;
}
private function resolveMinPax(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): int {
$prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$candidates = array_values(array_filter(
$prices,
fn (AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom,
));
return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\HttpFoundation\Request;
class AccommodationSessionManager
{
// Key kept as accommodation_inquiry for session continuity during the inquiry→booking rename.
public const SESSION_KEY = 'accommodation_inquiry';
public function getDto(Request $request): ?AccommodationBookingDto
{
$session = $request->getSession();
if (!$session->has(self::SESSION_KEY)) {
return null;
}
$dto = $session->get(self::SESSION_KEY);
if (!$dto instanceof AccommodationBookingDto) {
return null;
}
return $dto;
}
public function getOrFail(Request $request): AccommodationBookingDto
{
$dto = $this->getDto($request);
if (null === $dto) {
throw new AccommodationSessionNotFoundException();
}
return $dto;
}
public function save(Request $request, AccommodationBookingDto $dto): void
{
$request->getSession()->set(self::SESSION_KEY, $dto);
}
public function clear(Request $request): void
{
$request->getSession()->remove(self::SESSION_KEY);
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\BookingSummaryPricingDto;
use App\Form\Model\BookingSummaryVoucherDto;
use App\Model\BookingSummaryCmsHotelData;
use App\Model\CmsHotelData;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -126,7 +126,7 @@ class BookingSummaryAssembler
* Data is cached for 1 hour. This method can be called early in the booking
* flow to warm the cache.
*/
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelData
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?CmsHotelData
{
if (null === $hotelCode) {
return null;
@@ -144,7 +144,7 @@ class BookingSummaryAssembler
// Fetch CMS images (nice to have)
$images = $this->cmsDataService->getProductImages($productCode, $hotelCode);
return new BookingSummaryCmsHotelData(
return new CmsHotelData(
name: $baseHotel?->name,
address: $this->formatHotelAddress($baseHotel),
images: $images,
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Service;
class CalendarGridBuilder
{
private const array MONTH_NAMES = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
/**
* Builds a calendar structure for $count months starting from the 1st of $from's month.
*
* @return list<array{month: \DateTimeImmutable, label: string, weeks: list<list<array{date: \DateTimeImmutable, inMonth: bool}>>}>
*/
public function buildMonths(\DateTimeImmutable $from, int $count = 18): array
{
$months = [];
$base = $from->modify('first day of this month')->setTime(0, 0, 0);
for ($i = 0; $i < $count; $i++) {
$monthStart = $base->modify("+{$i} months");
$months[] = [
'month' => $monthStart,
'label' => self::MONTH_NAMES[(int) $monthStart->format('n') - 1].' '.$monthStart->format('Y'),
'weeks' => $this->buildWeeks($monthStart),
];
}
return $months;
}
/**
* @return list<list<array{date: \DateTimeImmutable, inMonth: bool}>>
*/
private function buildWeeks(\DateTimeImmutable $monthStart): array
{
$monthEnd = $monthStart->modify('last day of this month');
$monthKey = $monthStart->format('Y-m');
// Monday of the week containing the 1st (ISO week: Mon=1)
$isoDay = (int) $monthStart->format('N');
$gridStart = $monthStart->modify('-'.($isoDay - 1).' days');
// Sunday of the week containing the last day
$isoDay = (int) $monthEnd->format('N');
$gridEnd = $monthEnd->modify('+'.(7 - $isoDay).' days');
$weeks = [];
$current = $gridStart;
while ($current <= $gridEnd) {
$week = [];
for ($d = 0; $d < 7; $d++) {
$week[] = [
'date' => $current,
'inMonth' => $current->format('Y-m') === $monthKey,
];
$current = $current->modify('+1 day');
}
$weeks[] = $week;
}
return $weeks;
}
}
+180 -25
View File
@@ -2,13 +2,20 @@
namespace App\Service;
use App\Model\CmsHotelData;
use App\Model\CmsRegionData;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CmsDataProvider
{
public function __construct(private readonly HttpClientInterface $httpClient, private readonly string $apiKey)
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $cache,
private readonly string $apiKey,
) {
}
/**
@@ -27,33 +34,181 @@ class CmsDataProvider
return $result['hotel']['images'] ?? null;
}
/**
* @return array<string, mixed>|null
*/
public function getHotelImages(string $hotelCode): ?array
{
return $this->getHotelDetails($hotelCode)?->images;
}
public function getHotelDetails(string $hotelCode): ?CmsHotelData
{
$data = $this->fetchHotelDetails($hotelCode);
if (isset($data['success']) && false === $data['success']) {
return null;
}
return $this->mapHotelData($data);
}
/**
* @return array{
* success?: bool,
* message?: string,
* name?: string,
* address?: string,
* description?: string,
* features?: string,
* room_types?: string,
* additional_information?: string,
* images?: array<string, mixed>,
* icons?: array<string, bool|string>,
* region?: array{
* name: string,
* latitude: float,
* longitude: float,
* webcam: string,
* ski_area: string,
* ski_area_extended: string,
* description: string,
* news: string,
* length: int,
* altitude: int,
* lifts: int,
* images: array<string, mixed>,
* region_maps: list<string>,
* },
* }
*/
private function fetchHotelDetails(string $hotelCode): array
{
return $this->cache->get(
sprintf('cms_hotel_%s', $hotelCode),
function (ItemInterface $item) use ($hotelCode): array {
$item->expiresAfter(3600);
try {
$request = $this->httpClient->request('GET', 'api/hotel', [
'query' => [
'hotel' => $hotelCode,
'key' => $this->apiKey,
],
]);
} catch (ExceptionInterface $e) {
$item->expiresAfter(0);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
try {
$data = $request->toArray();
} catch (ExceptionInterface $e) {
$item->expiresAfter(0);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
return $data;
}
);
}
/**
* @param array<string, mixed> $data see {@see self::fetchHotelDetails()} for the shape
*/
private function mapHotelData(array $data): CmsHotelData
{
return new CmsHotelData(
name: $data['name'] ?? null,
address: $data['address'] ?? null,
images: $data['images'] ?? null,
description: $data['description'] ?? null,
features: $data['features'] ?? null,
roomTypes: $data['room_types'] ?? null,
additionalInformation: $data['additional_information'] ?? null,
icons: $data['icons'] ?? null,
region: isset($data['region']) ? $this->mapRegionData($data['region']) : null,
);
}
/**
* @param array<string, mixed> $region see {@see self::fetchHotelDetails()} for the shape
*/
private function mapRegionData(array $region): CmsRegionData
{
return new CmsRegionData(
name: $region['name'] ?? null,
latitude: self::toFloatOrNull($region['latitude'] ?? null),
longitude: self::toFloatOrNull($region['longitude'] ?? null),
webcam: $region['webcam'] ?? null,
skiArea: $region['ski_area'] ?? null,
skiAreaExtended: $region['ski_area_extended'] ?? null,
description: $region['description'] ?? null,
news: $region['news'] ?? null,
length: self::toIntOrNull($region['length'] ?? null),
altitude: self::toIntOrNull($region['altitude'] ?? null),
lifts: self::toIntOrNull($region['lifts'] ?? null),
images: $region['images'] ?? null,
regionMaps: $region['region_maps'] ?? null,
);
}
private static function toIntOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private static function toFloatOrNull(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
/** @return array<string, mixed> */
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
{
try {
$request = $this->httpClient->request('GET', 'api/product', [
'query' => [
'product' => $productCode,
'hotel' => $hotelCode,
'key' => $this->apiKey,
],
]);
} catch (ExceptionInterface $e) {
return [
'success' => false,
'message' => $e->getMessage(),
];
}
return $this->cache->get(
sprintf('cms_product_%s_%s', $productCode, $hotelCode ?? ''),
function (ItemInterface $item) use ($productCode, $hotelCode): array {
$item->expiresAfter(3600);
try {
$data = $request->toArray();
} catch (ExceptionInterface $e) {
return [
'success' => false,
'message' => $e->getMessage(),
];
}
try {
$request = $this->httpClient->request('GET', 'api/product', [
'query' => [
'product' => $productCode,
'hotel' => $hotelCode,
'key' => $this->apiKey,
],
]);
} catch (ExceptionInterface $e) {
$item->expiresAfter(0);
return $data;
return [
'success' => false,
'message' => $e->getMessage(),
];
}
try {
$data = $request->toArray();
} catch (ExceptionInterface $e) {
$item->expiresAfter(0);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
return $data;
}
);
}
}
+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,
];
}
}
+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;
}
}