wip: group inquiries and booking
This commit is contained in:
1 parent
225ed518e3
commit
1e59d38eb4
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }} %</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 %}
|
||||
|
||||
@@ -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 }} %</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user