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
+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;
}
}