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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user