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, * 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; } } // Additional services keep the repository order (manual position), which also makes the // groups appear in the order of their lowest-positioned member. $boardServices = $this->sortServicesByPriceThenLabel($boardServices); return [ 'boardServices' => $boardServices, 'additionalServices' => $additionalServices, 'groupedAdditionalServices' => $grouped, 'ungroupedAdditionalServices' => $ungrouped, ]; } /** * Free services (price 0) first, then alphabetically. * * @param BoardService[] $services * * @return BoardService[] */ private function sortServicesByPriceThenLabel(array $services): array { usort($services, static function (BoardService $a, BoardService $b): int { $aFree = 0 === $a->getPrice() ? 0 : 1; $bFree = 0 === $b->getPrice() ? 0 : 1; return $aFree <=> $bFree ?: strnatcasecmp($a->getLabel(), $b->getLabel()); }); return $services; } /** * 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); // An inquiry still needs an offer the office has to prepare, so it waits at Angefragt // until that offer goes out. A direct booking has been committed to by the customer // but still awaits the office's validation, hence Eingegangen rather than Bestätigt. $isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry; $booking->setOrigin($isInquiry ? AccommodationBookingOrigin::Offer : AccommodationBookingOrigin::Direct); $booking->setStatus($isInquiry ? AccommodationBookingStatus::Requested : AccommodationBookingStatus::Received); if (!$isInquiry) { $booking->setAcceptedAt(new \DateTimeImmutable()); } // 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); // A direct booking gets no customer mail here: it only reaches the customer once the // office has validated it and confirmed via confirmBooking(). Acknowledging an // inquiry is a different matter — it promises nothing, so it still goes out. if ($booking->isFromOffer()) { $this->sendCustomerConfirmationEmail($booking); } return $booking; } public function refreshPriceSnapshot(AccommodationBooking $booking): void { $breakdown = $this->breakdownCalculator->computeCurrent($booking); if (null === $breakdown) { $booking->clearPriceSnapshot(); return; } // The raw breakdown is what gets frozen; the discounted total comes from the same // calculator the templates read, so the stored number always matches what is shown. $booking->setPriceSnapshot( $breakdown, $this->breakdownCalculator->withDiscounts($booking, $breakdown)['discountedTotal'], (string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'), self::PRICING_VERSION, ); } public function sendNotificationEmail(AccommodationBooking $booking): void { $this->sendBookingEmail( $booking, $this->accommodationEmail, sprintf( 'Neue Unterkunfts%s: %s', $booking->isDirectBooking() ? 'buchung' : 'anfrage', $booking->getAccommodation()?->getName(), ), 'email/accommodation_booking.html.twig', 'Failed to send accommodation booking notification email', ); } /** * All accommodation mail is sent from the group desk address so customer replies * land there rather than in the general inbox. * * A booking can legitimately have no email yet (drafts, and offers created without * contact data), so a missing recipient is logged and skipped rather than thrown: * the caller's own work — accepting an offer, for instance — is already done and * must not fail over an undeliverable notification. */ private function sendBookingEmail( AccommodationBooking $booking, ?string $to, string $subject, string $template, string $errorMessage, bool $withPdf = false, ): void { if (null === $to || '' === trim($to)) { $this->logger->warning($errorMessage, [ 'booking_id' => $booking->getId(), 'error' => 'No recipient email address on the booking.', ]); return; } $breakdown = $this->breakdownCalculator->compute($booking); try { $this->mailer->createAndSendEmail( [ 'booking' => $booking, 'accessLink' => $this->accessLinkOrNull($booking), 'priceBreakdown' => $breakdown, 'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'] ?? null, ], [ 'from' => $this->accommodationEmail, 'to' => $to, 'subject' => $subject, 'template' => $template, 'attachments' => $withPdf ? $this->bookingPdfAttachment($booking) : [], ], ); } catch (\Throwable $e) { $this->logger->error($errorMessage, [ 'booking_id' => $booking->getId(), 'error' => $e->getMessage(), ]); } } /** * Sets accessLinkIssuedAt if the booking doesn't have one yet, whatever its status. * No email side effect — the confirmation email is always sent separately via * sendCustomerConfirmationEmail(), regardless of whether a link exists. */ public function issueAccessLink(AccommodationBooking $booking): void { if (null !== $booking->getAccessLinkIssuedAt()) { return; } $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $this->entityManager->flush(); } /** * Issues the access link only for a direct booking. Inquiries from the public flow * deliberately get no link — the office prepares the offer first and issues the link * when it publishes the offer. */ public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void { if (!$booking->isDirectBooking()) { return; } $this->issueAccessLink($booking); } /** * Serves both customer-facing messages of the pre-booking phase: the acknowledgement * that an inquiry arrived, and the offer itself. Offen is the status that means the * offer is out, so it is what decides the subject — the template makes the same * distinction via accessLink, which is always issued as the offer goes out. */ public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void { $subject = $booking->isOpen() ? 'Dein Angebot ist bereit' : 'Deine Anfrage ist bei uns eingegangen'; $this->sendBookingEmail( $booking, $booking->getEmail(), $subject, 'email/accommodation_booking_customer.html.twig', 'Failed to send accommodation booking customer confirmation email', ); } /** * 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(); } /** * Publishes the prepared offer: the single action that puts an offer in front of the * customer, and the only transition into Offen. Issues the access link the email links * to, so the two can never drift apart the way they could when sending was a side * effect of editing. * Idempotent — a no-op (including no email) for anything that is not awaiting an offer. */ public function sendOffer(AccommodationBooking $booking): void { if (!$booking->isDraft() && !$booking->isRequested()) { return; } $this->issueAccessLink($booking); $booking->setStatus(AccommodationBookingStatus::Open); $this->entityManager->flush(); $this->sendCustomerConfirmationEmail($booking); } /** * Closes a booking that is not going to happen. Deliberately silent: the office tells * the customer itself, so this only records the outcome. * Idempotent — a no-op for a booking that is already confirmed or discarded, since a * released confirmation is binding and must not be withdrawn behind the customer's back. */ public function discardBooking(AccommodationBooking $booking): void { if ($booking->isConfirmed() || $booking->isDiscarded()) { return; } $booking->setStatus(AccommodationBookingStatus::Discarded); $this->entityManager->flush(); } /** * Accepts an offer that is out with the customer and notifies the office. The origin * stays untouched, so the booking remains recognisable as offer-originated for the * rest of its life; only the status moves on to Eingegangen. * Idempotent — a no-op (including no emails) if the offer is not open any more. * * The customer deliberately gets no mail here: nothing is confirmed until the office * has validated the booking and released it via confirmBooking(). * * @param ?string $remarks null leaves the stored remark untouched, a string replaces it, * an empty string clears it */ public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void { // Offen means exactly one thing — an offer is out and awaiting this acceptance — // so the status alone is the whole guard. if (!$booking->isOpen()) { return; } if (null !== $remarks) { $trimmed = trim($remarks); $booking->setRemarks('' === $trimmed ? null : $trimmed); } $booking->setStatus(AccommodationBookingStatus::Received); $booking->setAcceptedAt(new \DateTimeImmutable()); $this->entityManager->flush(); $this->sendOfferAcceptedNotificationEmail($booking); } /** * Explicit office action after the requested services and capacities have been validated: * this is the moment the booking becomes binding for the customer, and the only place the * booking confirmation email is sent. * Idempotent — a no-op (including no email) for anything that is not awaiting validation. */ public function confirmBooking(AccommodationBooking $booking): void { if (!$booking->isReceived()) { return; } $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setConfirmedAt(new \DateTimeImmutable()); $this->entityManager->flush(); // The confirmation links to the booking view, so make sure a link exists. $this->issueAccessLink($booking); $this->sendBookingConfirmedCustomerEmail($booking); } public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void { $this->sendBookingEmail( $booking, $this->accommodationEmail, sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()), 'email/offer_accepted.html.twig', 'Failed to send offer accepted notification email', ); } public function sendBookingConfirmedCustomerEmail(AccommodationBooking $booking): void { $this->sendBookingEmail( $booking, $booking->getEmail(), 'Deine Buchung ist bestätigt', 'email/booking_confirmed_customer.html.twig', 'Failed to send booking confirmed customer email', withPdf: true, ); } /** * A missing price snapshot must not cost the customer their confirmation, so a failed * render degrades to sending the email without the document. Called only once a * recipient is known, so no PDF is rendered for a mail that never goes out. * * @return list */ private function bookingPdfAttachment(AccommodationBooking $booking): array { try { return [$this->pdfGenerator->createAttachment($booking)]; } catch (\Throwable $e) { $this->logger->warning('Failed to attach the booking PDF to the confirmation email', [ 'booking_id' => $booking->getId(), 'error' => $e->getMessage(), ]); return []; } } 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; } }