wip: group inquiries and booking

This commit is contained in:
Björn Fromme committed 2026-07-14 11:29:43 +02:00
1 parent 597b169b17
commit 732a0f1d19
33 files changed
+644 -95

No files matched your search

@@ -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)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260710130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store an authoritative price snapshot on accommodation bookings';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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(),
]);
}
}
@@ -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();
@@ -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();
@@ -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(),
]);
}
}
@@ -0,0 +1,52 @@
<?php
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;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SendAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/send-access-link', name: 'app_admin_accommodationbooking_send_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if (null === $booking->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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
+30 -48
View File
@@ -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(),
];
}
}
@@ -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());
+1 -1
View File
@@ -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);
+59 -1
View File
@@ -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<array{label: string, price: int, type: string, originalServiceId: int|null}> */
#[ORM\Column(type: Types::JSON)]
private array $additionalServices = [];
/** @var array<string, mixed>|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<array{label: string, price: int, type: string, originalServiceId: int|null}> */
public function getAdditionalServices(): array
{
return $this->additionalServices;
}
/** @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServices */
public function setAdditionalServices(array $additionalServices): self
{
$this->additionalServices = $additionalServices;
@@ -259,6 +275,48 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this;
}
/** @return array<string, mixed>|null */
public function getPriceBreakdown(): ?array
{
return $this->priceBreakdown;
}
/** @param array<string, mixed> $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;
@@ -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<string, mixed>|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;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final readonly class ContingentCalendarQuery
{
private const int MAX_RANGE_DAYS = 366;
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateFrom,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateTo,
) {
}
#[Assert\Callback]
public function validateRange(ExecutionContextInterface $context): void
{
$dateFrom = self::parseDate($this->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;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ContingentPricesQuery
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\Range(min: 1000, max: 9999)]
public int $year,
) {
}
}
@@ -16,12 +16,27 @@ class AccommodationBookingBreakdownCalculator
}
/**
* Recomputes the price breakdown from the booking's frozen snapshot data and current
* accommodation prices for the same date range.
* Returns the authoritative stored snapshot, falling back to current prices only for
* bookings created before snapshot persistence was introduced.
*
* @return array<string, mixed>|null null when no accommodation or dates are set
*/
public function compute(AccommodationBooking $booking): ?array
{
if (null !== $booking->getPriceBreakdown()) {
return $booking->getPriceBreakdown();
}
return $this->computeCurrent($booking);
}
/**
* Calculates against the current catalog. Use only while creating or explicitly editing
* a booking, or as a compatibility fallback for records created before price snapshots.
*
* @return array<string, mixed>|null
*/
public function computeCurrent(AccommodationBooking $booking): ?array
{
$accommodation = $booking->getAccommodation();
$dateFrom = $booking->getDateFrom();
+38 -14
View File
@@ -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<string, mixed> $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;
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du für <em>{{ booking.groupName }}</em> einen neuen Zugangslink generieren?
Ein zuvor generierter Link wird dadurch ungültig.
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du den aktuellen Zugangslink für <em>{{ booking.groupName }}</em> an
<em>{{ booking.email }}</em> senden?
</div>
{% endblock %}
@@ -77,7 +77,7 @@
{% if priceBreakdown is not null %}
<div>
<h2 class="text-lg font-bold mb-4">Preise</h2>
{% set currency = booking.accommodation.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<table class="w-full text-sm">
{% if priceBreakdown.basePrice > 0 %}
<tr>
@@ -138,7 +138,7 @@
</tr>
{% 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) %}
<tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
@@ -170,9 +170,14 @@
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
{% if accessLink %}
<p class="text-sm break-all">
<a href="{{ accessLink }}" target="_blank">{{ accessLink }}</a>
</p>
<button type="button"
class="button button--secondary button--small"
data-controller="clipboard"
data-clipboard-content-value="{{ accessLink }}"
data-clipboard-label-value="Kopiert!"
data-action="clipboard#copy">
{{ icon('copy', 'w-4 h-4') }} Link kopieren
</button>
<p class="text-xs text-gray-500 mt-1">
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
{% if accessLinkExpiresAt < date() %}
@@ -182,11 +187,24 @@
{% else %}
<p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p>
{% endif %}
<form method="post" action="{{ path('app_admin_accommodationbooking_generate_access_link', { 'id': booking.id }) }}" class="mt-2">
<button type="submit" class="button button--secondary button--small">
<div class="mt-2 flex items-center gap-2">
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
{{ accessLink ? 'Link erneut generieren' : 'Link generieren' }}
</button>
</form>
{% if accessLink %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link senden
</button>
{% endif %}
</div>
</div>
<div class="flex justify-between pt-8">
@@ -75,7 +75,7 @@
{% endif %}
{% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
{% if priceBreakdown.basePrice > 0 %}
@@ -124,7 +124,7 @@
{% endif %}
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
@@ -52,12 +52,12 @@
</table>
{% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
+5 -2
View File
@@ -38,7 +38,7 @@
{% endif %}
{% if priceBreakdown is not null %}
{% set currency = booking.accommodation.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Preise</h2>
<table class="w-full text-sm">
@@ -51,7 +51,7 @@
</tr>
{% 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) %}
<tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
@@ -67,6 +67,9 @@
{% if booking.inquiry %}
<form method="post" action="{{ app.request.requestUri }}">
<input type="hidden"
name="_token"
value="{{ csrf_token('accept_accommodation_offer_' ~ booking.uuid) }}">
<button type="submit" class="button button--primary">
Angebot verbindlich buchen
</button>
+6 -1
View File
@@ -14,7 +14,12 @@
</div>
{% block content %}{% endblock %}
<div class="mt-6 flex items-center justify-between">
<button type="button" class="button button--warning" {% block button_attribute %}hx-post="{{ app.request.uri }}"{% endblock %}>
<button type="button"
class="button button--warning"
{% block button_attribute %}
hx-post="{{ app.request.uri }}"
hx-vals="{{ { _token: csrf_token(csrf_token_id) }|json_encode|e('html_attr') }}"
{% endblock %}>
{% block button_confirm %}Ja{% endblock %}
</button>
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
@@ -79,7 +79,7 @@ class Step2ControllerTest extends TestCase
$bookingService
->expects(self::once())
->method('computeInquiryStatus')
->with(self::callback(static fn(AccommodationBookingDto $submittedDto): bool => 3 === $submittedDto->paxCount), [])
->with(self::callback(static fn (AccommodationBookingDto $submittedDto): bool => 3 === $submittedDto->paxCount), [])
->willReturn(new InquiryStatus(true, ['Mindestaufenthalt: 5 Nächte (gebucht: 4)']));
$controller = new TestableAccommodationStep2Controller(
@@ -51,7 +51,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setBoardServiceLabel('Halbpension');
$booking->setBoardServicePrice(1500);
$booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3);
$booking->setDiscount(1000);
$booking->setDiscount(10);
$booking->setIsInquiry(false);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
@@ -63,6 +63,7 @@ class AccommodationBookingControllerTest extends TestCase
->willReturn($booking);
$breakdown = ['total' => 12345, 'currency' => 'EUR'];
$booking->setPriceSnapshot($breakdown, 11111, 'EUR', 1);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator
->expects(self::once())
@@ -116,7 +117,10 @@ class AccommodationBookingControllerTest extends TestCase
'type' => 'flat',
],
],
'discount' => 1000,
'discount' => 10,
'totalPrice' => 11111,
'pricingCurrency' => 'EUR',
'pricingVersion' => 1,
'priceBreakdown' => $breakdown,
], $payload);
@@ -57,18 +57,54 @@ class OfferControllerTest extends TestCase
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking);
$controller = new OfferController(
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc', 'POST');
$request = Request::create(
'/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc',
'POST',
['_token' => 'valid-token'],
);
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
self::assertSame($request->getUri(), $response->headers->get('Location'));
self::assertSame('accept_accommodation_offer_'.$booking->getUuid(), $controller->csrfTokenId);
self::assertSame('valid-token', $controller->csrfToken);
}
public function testPostRejectsInvalidCsrfToken(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
$controller->csrfValid = false;
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
$controller->index(
$booking->getUuid(),
Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST', ['_token' => 'invalid']),
);
}
public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void
@@ -146,11 +182,22 @@ class OfferControllerTest extends TestCase
final class TestableOfferController extends OfferController
{
public bool $csrfValid = true;
public ?string $csrfTokenId = null;
public ?string $csrfToken = null;
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
$this->csrfTokenId = $id;
$this->csrfToken = $token;
return $this->csrfValid;
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentPricesQuery;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
class ContingentQueryTest extends TestCase
{
public function testCalendarQueryAcceptsValidRange(): void
{
$query = new ContingentCalendarQuery('HOTEL_1', '2026-01-01', '2026-12-31');
self::assertCount(0, $this->validator()->validate($query));
self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d'));
}
/**
* @dataProvider invalidCalendarQueries
*/
public function testCalendarQueryRejectsInvalidInput(ContingentCalendarQuery $query): void
{
self::assertGreaterThan(0, $this->validator()->validate($query)->count());
}
public function invalidCalendarQueries(): iterable
{
yield 'normalized invalid date' => [new ContingentCalendarQuery('HOTEL', '2026-02-31', '2026-03-01')];
yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')];
yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')];
yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')];
}
public function testPricesQueryRequiresFourDigitYear(): void
{
self::assertCount(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 2026)));
self::assertGreaterThan(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 26))->count());
}
private function validator(): \Symfony\Component\Validator\Validator\ValidatorInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator();
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\AccommodationBooking;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use PHPUnit\Framework\TestCase;
class AccommodationBookingBreakdownCalculatorTest extends TestCase
{
public function testComputeReturnsStoredSnapshotWithoutLoadingCurrentPrices(): void
{
$snapshot = ['total' => 12345, 'currency' => 'EUR'];
$booking = new AccommodationBooking();
$booking->setPriceSnapshot($snapshot, 12345, 'EUR', 1);
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
$calculator = new AccommodationBookingBreakdownCalculator(
new GroupsPriceCalculator(new PriceTimelineBuilder(), [
'runningCostsEur' => 0,
'runningCostsChf' => 0,
'undersubscription30Eur' => 0,
'undersubscription30Chf' => 0,
'undersubscription40Eur' => 0,
'undersubscription40Chf' => 0,
]),
$priceRepository,
);
self::assertSame($snapshot, $calculator->compute($booking));
}
}
@@ -175,7 +175,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendCustomerConfirmationEmail($booking);
}
public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void
public function testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
@@ -190,7 +190,7 @@ class AccommodationBookingServiceTest extends TestCase
$breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::once())->method('createAndSendEmail');
$mailer->expects(self::never())->method('createAndSendEmail');
$service = $this->createServiceWithAccommodation(
entityManager: $entityManager,
@@ -198,7 +198,7 @@ class AccommodationBookingServiceTest extends TestCase
breakdownCalculator: $breakdownCalculator,
);
$service->regenerateAccessLinkAndNotifyCustomer($booking);
$service->regenerateAccessLink($booking);
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
@@ -234,6 +234,27 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNull($booking->getAcceptedAt());
}
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void
{
$booking = new AccommodationBooking();
$booking->setDiscount(10);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator
->expects(self::once())
->method('computeCurrent')
->with($booking)
->willReturn(['total' => 12345, 'currency' => 'CHF']);
$service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator);
$service->refreshPriceSnapshot($booking);
self::assertSame(['total' => 12345, 'currency' => 'CHF'], $booking->getPriceBreakdown());
self::assertSame(11110, $booking->getTotalPrice());
self::assertSame('CHF', $booking->getPricingCurrency());
self::assertSame(1, $booking->getPricingVersion());
}
/**
* @param AccommodationPrice[] $prices
*/