From 732a0f1d19f58dc7e80e0e911ec0fec84bcc15e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 10 Jul 2026 14:05:28 +0200 Subject: [PATCH] wip: group inquiries and booking --- assets/controllers/clipboard_controller.js | 37 +++++++++ migrations/Version20260710130000.php | 26 +++++++ .../Admin/Accommodation/DeleteController.php | 5 ++ .../AccommodationBooking/CreateController.php | 1 + .../AccommodationBooking/EditController.php | 4 +- .../GenerateAccessLinkController.php | 16 +++- .../SendAccessLinkController.php | 52 +++++++++++++ .../AccommodationPrice/DeleteController.php | 8 +- .../AdditionalService/DeleteController.php | 5 ++ .../Admin/BoardService/DeleteController.php | 5 ++ .../BookingEditDraft/DeleteController.php | 5 ++ src/Controller/Api/ContingentController.php | 78 +++++++------------ src/Controller/Groups/OfferController.php | 4 + src/Controller/Groups/Step3Controller.php | 2 +- src/Entity/Groups/AccommodationBooking.php | 60 +++++++++++++- src/Model/AccommodationBookingApiResponse.php | 12 +++ src/Model/ContingentCalendarQuery.php | 78 +++++++++++++++++++ src/Model/ContingentPricesQuery.php | 21 +++++ ...ccommodationBookingBreakdownCalculator.php | 19 ++++- src/Service/AccommodationBookingService.php | 52 +++++++++---- .../modal_generate_access_link.html.twig | 8 ++ .../modal_send_access_link.html.twig | 8 ++ .../accommodation_booking/show.html.twig | 34 ++++++-- .../email/accommodation_booking.html.twig | 4 +- .../accommodation_booking_customer.html.twig | 4 +- templates/groups/booking/offer.html.twig | 7 +- templates/htmx_confirmation_modal.html.twig | 7 +- .../Accommodation/Step2ControllerTest.php | 2 +- .../AccommodationBookingControllerTest.php | 8 +- .../Controller/Groups/OfferControllerTest.php | 51 +++++++++++- tests/Model/ContingentQueryTest.php | 50 ++++++++++++ ...modationBookingBreakdownCalculatorTest.php | 39 ++++++++++ .../AccommodationBookingServiceTest.php | 27 ++++++- 33 files changed, 644 insertions(+), 95 deletions(-) create mode 100644 assets/controllers/clipboard_controller.js create mode 100644 migrations/Version20260710130000.php create mode 100644 src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php create mode 100644 src/Model/ContingentCalendarQuery.php create mode 100644 src/Model/ContingentPricesQuery.php create mode 100644 templates/admin/accommodation_booking/modal_generate_access_link.html.twig create mode 100644 templates/admin/accommodation_booking/modal_send_access_link.html.twig create mode 100644 tests/Model/ContingentQueryTest.php create mode 100644 tests/Service/AccommodationBookingBreakdownCalculatorTest.php diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js new file mode 100644 index 00000000..231035da --- /dev/null +++ b/assets/controllers/clipboard_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + + static values = { + content: String, + label: String, + } + + connect() { + this.originalLabel = this.element.innerText + } + + disconnect() { + clearTimeout(this.resetTimer) + } + + copy() { + navigator.clipboard.writeText(this.contentValue).then(() => { + this.dispatch('copied', { + detail: { + content: this.contentValue, + } + }) + }).catch((error) => { + console.error('Failed to copy to clipboard:', error) + }) + + if (this.hasLabelValue) { + this.element.innerText = this.labelValue + clearTimeout(this.resetTimer) + this.resetTimer = setTimeout(() => { + this.element.innerText = this.originalLabel + }, 3000) + } + } +} diff --git a/migrations/Version20260710130000.php b/migrations/Version20260710130000.php new file mode 100644 index 00000000..1e2dae38 --- /dev/null +++ b/migrations/Version20260710130000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking ADD price_breakdown JSON DEFAULT NULL, ADD total_price INT DEFAULT NULL, ADD pricing_currency VARCHAR(3) DEFAULT NULL, ADD pricing_version SMALLINT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP price_breakdown, DROP total_price, DROP pricing_currency, DROP pricing_version'); + } +} diff --git a/src/Controller/Admin/Accommodation/DeleteController.php b/src/Controller/Admin/Accommodation/DeleteController.php index c52c5719..5c7c34fc 100644 --- a/src/Controller/Admin/Accommodation/DeleteController.php +++ b/src/Controller/Admin/Accommodation/DeleteController.php @@ -25,6 +25,10 @@ class DeleteController extends AbstractController public function index(Accommodation $accommodation, Request $request): Response { if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_'.$accommodation->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($accommodation); $this->entityManager->flush(); @@ -40,6 +44,7 @@ class DeleteController extends AbstractController return $this->render('admin/accommodation/modal_delete.html.twig', [ 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(), ]); } } diff --git a/src/Controller/Admin/AccommodationBooking/CreateController.php b/src/Controller/Admin/AccommodationBooking/CreateController.php index 324ca647..1dfbe38e 100644 --- a/src/Controller/Admin/AccommodationBooking/CreateController.php +++ b/src/Controller/Admin/AccommodationBooking/CreateController.php @@ -34,6 +34,7 @@ class CreateController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $this->bookingService->refreshPriceSnapshot($booking); $this->entityManager->persist($booking); $this->entityManager->flush(); diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index 5c2e53ac..37c51363 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -62,7 +62,7 @@ class EditController extends AbstractController $currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId'); $currentAdditionalServices = array_values(array_filter( $additionalServices, - fn(AdditionalService $s) => in_array($s->getId(), $currentIds, true), + fn (AdditionalService $s) => in_array($s->getId(), $currentIds, true), )); } @@ -103,6 +103,8 @@ class EditController extends AbstractController } } + $this->bookingService->refreshPriceSnapshot($booking); + $this->entityManager->persist($booking); $this->entityManager->flush(); diff --git a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php index 82004b81..d814aecb 100644 --- a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php +++ b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Admin\AccommodationBooking; use App\Entity\Groups\AccommodationBooking; +use App\Htmx\HxRedirectResponse; use App\Service\AccommodationBookingService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -24,17 +25,24 @@ class GenerateAccessLinkController extends AbstractController public function index(AccommodationBooking $booking, Request $request): Response { if ($request->isMethod(Request::METHOD_POST)) { - $this->bookingService->regenerateAccessLinkAndNotifyCustomer($booking); + if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } - $this->addFlash('success', 'Der Zugangslink wurde generiert und dem Kunden per E-Mail zugestellt.'); + $this->bookingService->regenerateAccessLink($booking); + + $this->addFlash('success', 'Der Zugangslink wurde neu generiert.'); $this->logger->info('Generated accommodation booking access link', [ 'id' => $booking->getId(), ]); - return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); } - return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(), + ]); } } diff --git a/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php new file mode 100644 index 00000000..aeb8da53 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php @@ -0,0 +1,52 @@ +getAccessLinkIssuedAt()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('send_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->sendCustomerConfirmationEmail($booking); + + $this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.'); + + $this->logger->info('Sent accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_send_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'send_accommodation_booking_access_link_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPrice/DeleteController.php b/src/Controller/Admin/AccommodationPrice/DeleteController.php index 9bec201f..f925cdc3 100644 --- a/src/Controller/Admin/AccommodationPrice/DeleteController.php +++ b/src/Controller/Admin/AccommodationPrice/DeleteController.php @@ -20,12 +20,17 @@ class DeleteController extends AbstractController private readonly LoggerInterface $logger, ) { } + #[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')] public function index(AccommodationPrice $accommodationPrice, Request $request): Response { $accommodation = $accommodationPrice->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_price_'.$accommodationPrice->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($accommodationPrice); $this->entityManager->flush(); @@ -37,7 +42,7 @@ class DeleteController extends AbstractController 'date_range' => [ 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), - ] + ], ]); $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ @@ -50,6 +55,7 @@ class DeleteController extends AbstractController return $this->render('admin/accommodation_price/modal_delete.html.twig', [ 'accommodationPrice' => $accommodationPrice, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(), ]); } } diff --git a/src/Controller/Admin/AdditionalService/DeleteController.php b/src/Controller/Admin/AdditionalService/DeleteController.php index 9b595291..dabd1d1d 100644 --- a/src/Controller/Admin/AdditionalService/DeleteController.php +++ b/src/Controller/Admin/AdditionalService/DeleteController.php @@ -27,6 +27,10 @@ class DeleteController extends AbstractController $accommodation = $additionalService->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_additional_service_'.$additionalService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($additionalService); $this->entityManager->flush(); @@ -48,6 +52,7 @@ class DeleteController extends AbstractController return $this->render('admin/additional_service/modal_delete.html.twig', [ 'additionalService' => $additionalService, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(), ]); } } diff --git a/src/Controller/Admin/BoardService/DeleteController.php b/src/Controller/Admin/BoardService/DeleteController.php index 73bd4896..c61d3aad 100644 --- a/src/Controller/Admin/BoardService/DeleteController.php +++ b/src/Controller/Admin/BoardService/DeleteController.php @@ -27,6 +27,10 @@ class DeleteController extends AbstractController $accommodation = $boardService->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_board_service_'.$boardService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($boardService); $this->entityManager->flush(); @@ -48,6 +52,7 @@ class DeleteController extends AbstractController return $this->render('admin/board_service/modal_delete.html.twig', [ 'boardService' => $boardService, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_board_service_'.$boardService->getId(), ]); } } diff --git a/src/Controller/Admin/BookingEditDraft/DeleteController.php b/src/Controller/Admin/BookingEditDraft/DeleteController.php index 84713618..c4a3a3c1 100644 --- a/src/Controller/Admin/BookingEditDraft/DeleteController.php +++ b/src/Controller/Admin/BookingEditDraft/DeleteController.php @@ -28,6 +28,10 @@ class DeleteController extends AbstractController public function index(BookingEditDraft $draft, Request $request): Response { if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_booking_edit_draft_'.$draft->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($draft); $this->entityManager->flush(); @@ -43,6 +47,7 @@ class DeleteController extends AbstractController return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [ 'draft' => $draft, + 'csrf_token_id' => 'delete_booking_edit_draft_'.$draft->getId(), ]); } } diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 70d1e26a..4bc8bb8d 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -8,14 +8,16 @@ use App\BpnConnect\ContingentsClient; use App\BpnConnect\Exception\BpnConnectException; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\PriceType; +use App\Model\ContingentCalendarQuery; +use App\Model\ContingentPricesQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; use App\Service\PriceTimelineBuilder; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Cache\CacheInterface; @@ -35,30 +37,20 @@ class ContingentController extends AbstractController } #[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])] - public function prices(Request $request): JsonResponse - { - $hotelCode = $request->query->get('hotelCode'); - $yearRaw = $request->query->get('year'); - - if (!$hotelCode || !$yearRaw) { - return $this->json(['error' => 'hotelCode and year are required.'], Response::HTTP_BAD_REQUEST); - } - - if (!preg_match('/^\d{4}$/', $yearRaw)) { - return $this->json(['error' => 'year must be a 4-digit integer.'], Response::HTTP_BAD_REQUEST); - } - - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + public function prices( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentPricesQuery $query, + ): JsonResponse { + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } - $year = (int) $yearRaw; - $yearStart = new \DateTimeImmutable("{$year}-01-01"); - $yearEnd = new \DateTimeImmutable("{$year}-12-31"); + $yearStart = new \DateTimeImmutable("{$query->year}-01-01"); + $yearEnd = new \DateTimeImmutable("{$query->year}-12-31"); - $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $yearStart, $yearEnd); + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd); $currency = $accommodation->getCurrency(); @@ -66,41 +58,31 @@ class ContingentController extends AbstractController } #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] - public function calendar(Request $request): JsonResponse - { - $hotelCode = $request->query->get('hotelCode'); - $dateFromRaw = $request->query->get('dateFrom'); - $dateToRaw = $request->query->get('dateTo'); + public function calendar( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentCalendarQuery $query, + ): JsonResponse { + $dateFrom = $query->dateFromDate(); + $dateTo = $query->dateToDate(); - if (!$hotelCode || !$dateFromRaw || !$dateToRaw) { - return $this->json(['error' => 'hotelCode, dateFrom and dateTo are required.'], Response::HTTP_BAD_REQUEST); - } - - $dateFrom = \DateTimeImmutable::createFromFormat('Y-m-d', $dateFromRaw)?->setTime(0, 0); - $dateTo = \DateTimeImmutable::createFromFormat('Y-m-d', $dateToRaw)?->setTime(0, 0); - - if (!$dateFrom || !$dateTo) { - return $this->json(['error' => 'dateFrom and dateTo must be in Y-m-d format.'], Response::HTTP_BAD_REQUEST); - } - - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } try { - $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromRaw, $dateToRaw); - $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelCode, $dateFromRaw, $dateToRaw) { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo); + $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) { $item->expiresAfter(3600); - return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromRaw, $dateToRaw); + return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo); }); } catch (BpnConnectException|InvalidArgumentException $e) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); } - $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo); $currency = $accommodation->getCurrency(); @@ -114,6 +96,7 @@ class ContingentController extends AbstractController /** * @param AccommodationPrice[] $prices + * * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null} */ private function enrichEntry(string $date, string $status, array $prices, string $currency): array @@ -123,14 +106,14 @@ class ContingentController extends AbstractController // dateFrom and dateTo are both inclusive (last night, not checkout day) $candidates = array_filter( $prices, - fn($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, ); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); $defaultPrice = null; - if ($winner?->getType() === PriceType::DISCOUNT) { - $defaults = array_filter($candidates, fn($p) => $p->getType() === null); + if (PriceType::DISCOUNT === $winner?->getType()) { + $defaults = array_filter($candidates, fn ($p) => null === $p->getType()); $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); } @@ -138,14 +121,13 @@ class ContingentController extends AbstractController 'date' => $date, 'status' => $status, 'type' => $winner?->getType()?->value, - 'pricePerNight' => $winner !== null ? round($winner->getPricePerNight() / 100, 2) : null, - 'defaultPricePerNight' => $defaultPrice !== null ? round($defaultPrice->getPricePerNight() / 100, 2) : null, - 'priceAdditionalPerson' => $winner !== null ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, - 'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, + 'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null, + 'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null, + 'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, + 'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, 'currency' => $currency, 'includedPax' => $winner?->getIncludedPax(), 'minNights' => $winner?->getMinNights(), ]; } - } diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index 13be392a..0684d539 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -34,6 +34,10 @@ class OfferController extends AbstractController } if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) { + if (!$this->isCsrfTokenValid('accept_accommodation_offer_'.$booking->getUuid(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->bookingService->acceptBooking($booking); return new RedirectResponse($request->getUri()); diff --git a/src/Controller/Groups/Step3Controller.php b/src/Controller/Groups/Step3Controller.php index fa442136..9f3dd6f6 100644 --- a/src/Controller/Groups/Step3Controller.php +++ b/src/Controller/Groups/Step3Controller.php @@ -150,7 +150,7 @@ class Step3Controller extends AbstractAccommodationController $booking = $this->bookingService->persist($dto, $accommodation, $prices, $services); $this->bookingService->issueAccessLinkForDirectBooking($booking); - $this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown); + $this->bookingService->sendNotificationEmail($booking); $this->bookingService->sendCustomerConfirmationEmail($booking); $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); $this->sessionManager->clear($request); diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index 449a1ec2..fe81f7d4 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -10,9 +10,9 @@ use App\Entity\TimestampableEntity; use App\Entity\TimestampableEntityInterface; use App\Enum\Groups\AdditionalServiceType; use App\Repository\Groups\AccommodationBookingRepository; -use Symfony\Component\Uid\Uuid; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)] @@ -57,9 +57,23 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt #[ORM\Column(nullable: true)] private ?int $boardServiceOriginalId = null; + /** @var list */ #[ORM\Column(type: Types::JSON)] private array $additionalServices = []; + /** @var array|null */ + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $priceBreakdown = null; + + #[ORM\Column(nullable: true)] + private ?int $totalPrice = null; + + #[ORM\Column(length: 3, nullable: true)] + private ?string $pricingCurrency = null; + + #[ORM\Column(type: Types::SMALLINT, nullable: true)] + private ?int $pricingVersion = null; + #[ORM\Column] private bool $isInquiry = false; @@ -235,11 +249,13 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } + /** @return list */ public function getAdditionalServices(): array { return $this->additionalServices; } + /** @param list $additionalServices */ public function setAdditionalServices(array $additionalServices): self { $this->additionalServices = $additionalServices; @@ -259,6 +275,48 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } + /** @return array|null */ + public function getPriceBreakdown(): ?array + { + return $this->priceBreakdown; + } + + /** @param array $priceBreakdown */ + public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self + { + $this->priceBreakdown = $priceBreakdown; + $this->totalPrice = $totalPrice; + $this->pricingCurrency = $currency; + $this->pricingVersion = $version; + + return $this; + } + + public function clearPriceSnapshot(): self + { + $this->priceBreakdown = null; + $this->totalPrice = null; + $this->pricingCurrency = null; + $this->pricingVersion = null; + + return $this; + } + + public function getTotalPrice(): ?int + { + return $this->totalPrice; + } + + public function getPricingCurrency(): ?string + { + return $this->pricingCurrency; + } + + public function getPricingVersion(): ?int + { + return $this->pricingVersion; + } + public function isInquiry(): bool { return $this->isInquiry; diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 708e32dc..c9d52f78 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -61,6 +61,15 @@ final readonly class AccommodationBookingApiResponse #[Groups(['api:single'])] public ?int $discount; + #[Groups(['api:single'])] + public ?int $totalPrice; + + #[Groups(['api:single'])] + public ?string $pricingCurrency; + + #[Groups(['api:single'])] + public ?int $pricingVersion; + /** * @var array|null */ @@ -90,6 +99,9 @@ final readonly class AccommodationBookingApiResponse $booking->getAdditionalServices(), ); $this->discount = $booking->getDiscount(); + $this->totalPrice = $booking->getTotalPrice(); + $this->pricingCurrency = $booking->getPricingCurrency(); + $this->pricingVersion = $booking->getPricingVersion(); $this->priceBreakdown = $priceBreakdown; } } diff --git a/src/Model/ContingentCalendarQuery.php b/src/Model/ContingentCalendarQuery.php new file mode 100644 index 00000000..bdf05290 --- /dev/null +++ b/src/Model/ContingentCalendarQuery.php @@ -0,0 +1,78 @@ +dateFrom); + $dateTo = self::parseDate($this->dateTo); + + if (null === $dateFrom || null === $dateTo) { + return; + } + + if ($dateTo < $dateFrom) { + $context->buildViolation('dateTo must not be before dateFrom.') + ->atPath('dateTo') + ->addViolation(); + + return; + } + + if ($dateFrom->diff($dateTo)->days > self::MAX_RANGE_DAYS) { + $context->buildViolation(sprintf('The date range must not exceed %d days.', self::MAX_RANGE_DAYS)) + ->atPath('dateTo') + ->addViolation(); + } + } + + public function dateFromDate(): \DateTimeImmutable + { + return self::parseDate($this->dateFrom) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + public function dateToDate(): \DateTimeImmutable + { + return self::parseDate($this->dateTo) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + private static function parseDate(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = \DateTimeImmutable::getLastErrors(); + + if (false === $date || (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + return null; + } + + return $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/src/Model/ContingentPricesQuery.php b/src/Model/ContingentPricesQuery.php new file mode 100644 index 00000000..c6d9d9fb --- /dev/null +++ b/src/Model/ContingentPricesQuery.php @@ -0,0 +1,21 @@ +|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|null + */ + public function computeCurrent(AccommodationBooking $booking): ?array { $accommodation = $booking->getAccommodation(); $dateFrom = $booking->getDateFrom(); diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 41747082..0dd47dba 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -22,6 +22,8 @@ use Psr\Log\LoggerInterface; class AccommodationBookingService { + private const int PRICING_VERSION = 1; + public function __construct( private readonly AccommodationRepository $accommodationRepo, private readonly AccommodationPriceRepository $priceRepo, @@ -187,7 +189,7 @@ class AccommodationBookingService $candidates = array_values(array_filter( $prices, - fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, + fn (AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, )); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); @@ -224,14 +226,14 @@ class AccommodationBookingService * 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 AccommodationPrice[] $prices * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services */ public function persist( AccommodationBookingDto $dto, - Accommodation $accommodation, - array $prices, - array $services, + Accommodation $accommodation, + array $prices, + array $services, ): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setAccommodation($accommodation); @@ -281,20 +283,43 @@ class AccommodationBookingService $booking->setCity($dto->city); $booking->setRemarks($dto->remarks); + $this->refreshPriceSnapshot($booking); + $this->entityManager->persist($booking); $this->entityManager->flush(); return $booking; } - /** @param array $priceBreakdown */ - public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void + public function refreshPriceSnapshot(AccommodationBooking $booking): void + { + $breakdown = $this->breakdownCalculator->computeCurrent($booking); + if (null === $breakdown) { + $booking->clearPriceSnapshot(); + + return; + } + + $total = (int) ($breakdown['total'] ?? 0); + if (null !== $booking->getDiscount()) { + $total -= (int) round($total * $booking->getDiscount() / 100); + } + + $booking->setPriceSnapshot( + $breakdown, + $total, + (string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'), + self::PRICING_VERSION, + ); + } + + public function sendNotificationEmail(AccommodationBooking $booking): void { try { $this->mailer->createAndSendEmail( [ 'booking' => $booking, - 'priceBreakdown' => $priceBreakdown, + 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [], 'accessLink' => $this->accessLinkOrNull($booking), ], [ @@ -362,15 +387,14 @@ class AccommodationBookingService } /** - * Explicit admin action: (re)issues the access link — invalidating any previously issued - * link for this booking — and sends the customer confirmation email, now including it. + * 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 regenerateAccessLinkAndNotifyCustomer(AccommodationBooking $booking): void + public function regenerateAccessLink(AccommodationBooking $booking): void { $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $this->entityManager->flush(); - - $this->sendCustomerConfirmationEmail($booking); } /** @@ -421,7 +445,7 @@ class AccommodationBookingService $prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); $candidates = array_values(array_filter( $prices, - fn(AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom, + fn (AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom, )); return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1; diff --git a/templates/admin/accommodation_booking/modal_generate_access_link.html.twig b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig new file mode 100644 index 00000000..edd3e831 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du für {{ booking.groupName }} einen neuen Zugangslink generieren? + Ein zuvor generierter Link wird dadurch ungültig. +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_send_access_link.html.twig b/templates/admin/accommodation_booking/modal_send_access_link.html.twig new file mode 100644 index 00000000..927091ff --- /dev/null +++ b/templates/admin/accommodation_booking/modal_send_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den aktuellen Zugangslink für {{ booking.groupName }} an + {{ booking.email }} senden? +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index ab7a7044..e9993855 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -77,7 +77,7 @@ {% if priceBreakdown is not null %}

Preise

- {% set currency = booking.accommodation.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} {% if priceBreakdown.basePrice > 0 %} @@ -138,7 +138,7 @@ {% if booking.discount is not null %} {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} - {% set discountedTotal = priceBreakdown.total - discountAmount %} + {% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %} @@ -170,9 +170,14 @@

Zugangslink

{% if accessLink %} -

- {{ accessLink }} -

+

Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }} {% if accessLinkExpiresAt < date() %} @@ -182,11 +187,24 @@ {% else %}

Es wurde noch kein Zugangslink generiert.

{% endif %} -
- - + {% if accessLink %} + + {% endif %} +
diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index f46ea9d1..d580ed9c 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -75,7 +75,7 @@ {% endif %} {% if priceBreakdown is not empty %} - {% set currency = priceBreakdown.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}

Preise

Rabatt {{ booking.discount }} % – {{ (discountAmount / 100) | format_currency(currency) }}
{% if priceBreakdown.basePrice > 0 %} @@ -124,7 +124,7 @@ {% endif %} - +
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
{% endif %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig index f40f0318..413a8a4d 100644 --- a/templates/email/accommodation_booking_customer.html.twig +++ b/templates/email/accommodation_booking_customer.html.twig @@ -52,12 +52,12 @@ {% if priceBreakdown is not empty %} - {% set currency = priceBreakdown.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}

Preise

- +
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
{% endif %} diff --git a/templates/groups/booking/offer.html.twig b/templates/groups/booking/offer.html.twig index 0e41582f..1958e88b 100644 --- a/templates/groups/booking/offer.html.twig +++ b/templates/groups/booking/offer.html.twig @@ -38,7 +38,7 @@ {% endif %} {% if priceBreakdown is not null %} - {% set currency = booking.accommodation.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}

Preise

@@ -51,7 +51,7 @@ {% if booking.discount is not null %} {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} - {% set discountedTotal = priceBreakdown.total - discountAmount %} + {% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %} @@ -67,6 +67,9 @@ {% if booking.inquiry %} + diff --git a/templates/htmx_confirmation_modal.html.twig b/templates/htmx_confirmation_modal.html.twig index 7a3cd937..551814f3 100644 --- a/templates/htmx_confirmation_modal.html.twig +++ b/templates/htmx_confirmation_modal.html.twig @@ -14,7 +14,12 @@ {% block content %}{% endblock %}
-
Rabatt {{ booking.discount }} % – {{ (discountAmount / 100) | format_currency(currency) }}