671 lines
25 KiB
PHP
671 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Email\EmailAttachmentInterface;
|
|
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\Enum\Groups\AccommodationBookingOrigin;
|
|
use App\Enum\Groups\AccommodationBookingStatus;
|
|
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 AccommodationBookingPdfGenerator $pdfGenerator,
|
|
private readonly string $accommodationEmail,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
// 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<EmailAttachmentInterface>
|
|
*/
|
|
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;
|
|
}
|
|
}
|