wip: group inquiries and bookings

This commit is contained in:
Björn Fromme committed 2026-07-16 09:03:42 +02:00
1 parent 41424de151
commit 1a653726b4
115 files changed
+8107 -424

No files matched your search

@@ -0,0 +1,582 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AdditionalServiceType;
use App\Enum\Groups\Season;
use App\Repository\Groups\AccommodationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:groups:import-legacy-data',
description: 'Imports Accommodation/AccommodationPrice/BoardService/AdditionalService data from the legacy TYPO3 CSV exports (hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv)',
)]
class ImportGroupsLegacyDataCommand extends Command
{
private const int DEFAULT_MIN_NIGHTS = 1;
private const Season DEFAULT_SEASON = Season::SECONDARY;
/** @var array<int, string> maps groupspriceoption "type" column to AdditionalServiceType */
private const array OPTION_TYPE_MAP = [
1 => AdditionalServiceType::Flat,
2 => AdditionalServiceType::PerPerson,
3 => AdditionalServiceType::PerNight,
4 => AdditionalServiceType::PerPersonPerNight,
];
/** @var array<string, Season> maps seasons.csv "token" column to Season, per translations/messages.de.yaml */
private const array SEASON_TOKEN_MAP = [
'HS' => Season::PEAK,
'NS' => Season::SECONDARY,
'VNS' => Season::ADV_SECONDARY,
];
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('path', InputArgument::OPTIONAL, 'Directory containing hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv', 'temp')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and report without persisting anything')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$path = rtrim((string) $input->getArgument('path'), '/');
foreach (['hotels.csv', 'groupspriceconfig.csv', 'groupspriceboard.csv', 'groupspriceoption.csv', 'seasons.csv'] as $file) {
if (!is_readable($path.'/'.$file)) {
$io->error(sprintf('Cannot read %s/%s', $path, $file));
return Command::FAILURE;
}
}
$hotels = $this->readCsv($path.'/hotels.csv');
$configRows = $this->readCsv($path.'/groupspriceconfig.csv');
$boardRows = $this->readCsv($path.'/groupspriceboard.csv');
$optionRows = $this->readCsv($path.'/groupspriceoption.csv');
$seasonRows = $this->readCsv($path.'/seasons.csv', ',');
$currencyByHotelUid = $this->deriveCurrencies($configRows);
$seasonRanges = $this->parseSeasonRanges($seasonRows);
$created = [];
$updated = [];
/** @var array<int, Accommodation> $accommodationByUid */
$accommodationByUid = [];
foreach ($hotels as $row) {
$uid = (int) $row['uid'];
$code = trim($row['code']);
$name = trim($row['name']);
$currency = $currencyByHotelUid[$uid] ?? 'EUR';
$existing = $this->accommodationRepository->findOneByCmsCode($code)
?? $this->accommodationRepository->findOneByCalendarCode($code);
if (null !== $existing) {
$existing->setName($name);
$existing->setCurrency($currency);
foreach ($existing->getAccommodationPrices()->toArray() as $price) {
$existing->removeAccommodationPrice($price);
if (!$dryRun) {
$this->entityManager->remove($price);
}
}
foreach ($existing->getBoardServices()->toArray() as $board) {
$existing->removeBoardService($board);
if (!$dryRun) {
$this->entityManager->remove($board);
}
}
foreach ($existing->getAdditionalServices()->toArray() as $service) {
$existing->removeAdditionalService($service);
if (!$dryRun) {
$this->entityManager->remove($service);
}
}
$accommodationByUid[$uid] = $existing;
$updated[] = sprintf('%s (%s) — replacing existing prices/board/services', $name, $code);
continue;
}
$existingByCalendarCode = null;
foreach ($accommodationByUid as $a) {
// guard against double-import: a prior run may already have created this one
if ($a->getCalendarCode() === $code) {
$existingByCalendarCode = $a;
break;
}
}
if (null !== $existingByCalendarCode) {
$accommodationByUid[$uid] = $existingByCalendarCode;
continue;
}
$accommodation = new Accommodation();
$accommodation->setCalendarCode($code);
$accommodation->setName($name);
$accommodation->setCurrency($currency);
$accommodation->setMaxAdolescentAge(0);
if (!$dryRun) {
$this->entityManager->persist($accommodation);
}
$accommodationByUid[$uid] = $accommodation;
$created[] = sprintf('%s (%s, %s)', $name, $code, $currency);
}
$knownUids = array_keys($accommodationByUid);
[$priceCount, $skippedPriceUids, $unmatchedSeasonCount] = $this->importPrices($configRows, $accommodationByUid, $knownUids, $seasonRanges, $dryRun);
[$boardCount, $skippedBoardUids] = $this->importBoardServices($boardRows, $accommodationByUid, $knownUids, $dryRun);
[$optionCount, $skippedOptionUids, $optionsNeedingDefaultRange] = $this->importAdditionalServices($optionRows, $accommodationByUid, $knownUids, $dryRun);
// BoardService/AdditionalService need a date range; derive it from each
// accommodation's freshly-imported AccommodationPrice rows now that they exist.
// AdditionalServices whose title carried its own year keep the scoped range set above.
$this->applyDateRangeToServices($accommodationByUid, $optionsNeedingDefaultRange);
if (!$dryRun) {
$this->entityManager->flush();
}
$io->section($dryRun ? 'Dry run summary' : 'Import summary');
$io->writeln(sprintf('Accommodations created: %d', \count($created)));
foreach ($created as $line) {
$io->writeln(' + '.$line);
}
$io->writeln(sprintf('Accommodations updated: %d', \count($updated)));
foreach ($updated as $line) {
$io->writeln(' ~ '.$line);
}
$io->writeln(sprintf('AccommodationPrice rows imported: %d', $priceCount));
if ($unmatchedSeasonCount > 0) {
$io->writeln(sprintf(' of which %d fell outside all seasons.csv ranges and defaulted to %s', $unmatchedSeasonCount, self::DEFAULT_SEASON->value));
}
$io->writeln(sprintf('BoardService rows imported: %d', $boardCount));
$io->writeln(sprintf('AdditionalService rows imported: %d', $optionCount));
$skippedUids = array_unique(array_merge($skippedPriceUids, $skippedBoardUids, $skippedOptionUids));
sort($skippedUids);
if ([] !== $skippedUids) {
$io->warning(sprintf(
'Skipped rows referencing hotel uid(s) not present in hotels.csv: %s',
implode(', ', $skippedUids),
));
}
$io->note('Dropped legacy columns with no entity equivalent (verified all-zero or unused): bonus_card_included, price_additional_person_group_1/2/3[_chf], ignore_with_board.');
if ($dryRun) {
$io->note('Dry run — nothing was persisted.');
}
$io->success('Done.');
return Command::SUCCESS;
}
/**
* @return list<array<string, string>>
*/
private function readCsv(string $file, string $separator = ';'): array
{
$handle = fopen($file, 'r');
if (false === $handle) {
throw new \RuntimeException(sprintf('Unable to open %s', $file));
}
$header = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"');
if (false === $header) {
fclose($handle);
return [];
}
$rows = [];
while (false !== ($row = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"'))) {
$rows[] = array_combine($header, $row);
}
fclose($handle);
return $rows;
}
/**
* Derives EUR/CHF per hotel uid from groupspriceconfig.csv: if any row for that hotel
* has a non-zero price_chf, the hotel is priced in CHF, otherwise EUR.
*
* @param list<array<string, string>> $configRows
*
* @return array<int, string>
*/
private function deriveCurrencies(array $configRows): array
{
$currencies = [];
foreach ($configRows as $row) {
$uid = (int) $row['hotel'];
if ($this->parseDecimal($row['price_chf']) > 0.0) {
$currencies[$uid] = 'CHF';
} elseif (!isset($currencies[$uid])) {
$currencies[$uid] = 'EUR';
}
}
return $currencies;
}
private function parseDecimal(string $value): float
{
$value = trim($value);
if ('' === $value) {
return 0.0;
}
return (float) str_replace(',', '.', $value);
}
private function toCents(string $value): int
{
return (int) round($this->parseDecimal($value) * 100);
}
private function priceColumn(string $currency): string
{
return 'CHF' === $currency ? 'price_chf' : 'price';
}
/**
* Parses seasons.csv (token,date_from,date_to; date_to exclusive, same convention as
* groupspriceconfig.csv) into Season-tagged ranges, skipping unrecognized tokens.
*
* @param list<array<string, string>> $rows
*
* @return list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}>
*/
private function parseSeasonRanges(array $rows): array
{
$ranges = [];
foreach ($rows as $row) {
$season = self::SEASON_TOKEN_MAP[trim($row['token'])] ?? null;
if (null === $season) {
continue;
}
$ranges[] = [
'season' => $season,
'dateFrom' => new \DateTimeImmutable(trim($row['date_from'])),
'dateTo' => new \DateTimeImmutable(trim($row['date_to'])),
];
}
return $ranges;
}
/**
* Resolves the season with the largest night-count overlap with [$dateFrom, $dateToExclusive)
* among $seasonRanges. Falls back to DEFAULT_SEASON (with matched=false) when the price
* period doesn't overlap any season range at all (e.g. a gap in seasons.csv).
*
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
*
* @return array{0: Season, 1: bool}
*/
private function resolveSeason(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateToExclusive, array $seasonRanges): array
{
$bestSeason = null;
$bestOverlapDays = 0;
foreach ($seasonRanges as $range) {
$overlapStart = max($dateFrom, $range['dateFrom']);
$overlapEnd = min($dateToExclusive, $range['dateTo']);
if ($overlapStart >= $overlapEnd) {
continue;
}
$overlapDays = $overlapStart->diff($overlapEnd)->days;
if ($overlapDays > $bestOverlapDays) {
$bestOverlapDays = $overlapDays;
$bestSeason = $range['season'];
}
}
return [$bestSeason ?? self::DEFAULT_SEASON, null !== $bestSeason];
}
/**
* @param list<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
*
* @return array{0: int, 1: list<int>, 2: int}
*/
private function importPrices(array $rows, array $accommodationByUid, array $knownUids, array $seasonRanges, bool $dryRun): array
{
$count = 0;
$skipped = [];
$unmatchedSeasonCount = 0;
foreach ($rows as $row) {
$uid = (int) $row['hotel'];
if (!\in_array($uid, $knownUids, true)) {
$skipped[] = $uid;
continue;
}
$accommodation = $accommodationByUid[$uid];
$column = $this->priceColumn($accommodation->getCurrency());
$additionalColumn = 'CHF' === $accommodation->getCurrency()
? 'price_additional_person_chf'
: 'price_additional_person';
$rawDateFrom = (new \DateTimeImmutable('@'.$row['date_from']))->setTime(0, 0);
// Source date_to is exclusive; AccommodationPrice::dateTo is the last included night.
$rawDateToExclusive = (new \DateTimeImmutable('@'.$row['date_to']))->setTime(0, 0);
[$season, $matched] = $this->resolveSeason($rawDateFrom, $rawDateToExclusive, $seasonRanges);
if (!$matched) {
++$unmatchedSeasonCount;
}
$price = new AccommodationPrice();
$price->setDateFrom($rawDateFrom);
$price->setDateTo($rawDateToExclusive->modify('-1 day'));
$price->setSeason($season);
$price->setIncludedPax((int) $row['persons_included']);
$price->setPricePerNight($this->toCents($row[$column]));
$price->setPriceAdditionalPerson($this->toCents($row[$additionalColumn]));
$price->setMinNights(self::DEFAULT_MIN_NIGHTS);
$price->setType(null);
$price->setAcceptUndersubscription(false);
$price->setAcceptShortTerm(false);
$accommodation->addAccommodationPrice($price);
if (!$dryRun) {
$this->entityManager->persist($price);
}
++$count;
}
return [$count, $skipped, $unmatchedSeasonCount];
}
/**
* @param list<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
*
* @return array{0: int, 1: list<int>}
*/
private function importBoardServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array
{
$count = 0;
$skipped = [];
foreach ($rows as $row) {
$uid = (int) $row['hotel'];
if (!\in_array($uid, $knownUids, true)) {
$skipped[] = $uid;
continue;
}
$accommodation = $accommodationByUid[$uid];
$column = $this->priceColumn($accommodation->getCurrency());
$board = new BoardService();
$board->setLabel(trim($row['title']));
$board->setPrice($this->toCents($row[$column]));
// dateFrom/dateTo filled in applyDateRangeToServices() once prices are known.
$board->setDateFrom(new \DateTimeImmutable('today'));
$board->setDateTo(new \DateTimeImmutable('today'));
$accommodation->addBoardService($board);
if (!$dryRun) {
$this->entityManager->persist($board);
}
++$count;
}
return [$count, $skipped];
}
/**
* @param list<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
*
* @return array{0: int, 1: list<int>, 2: list<AdditionalService>}
*/
private function importAdditionalServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array
{
$count = 0;
$skipped = [];
$needsDefaultRange = [];
foreach ($rows as $row) {
$uid = (int) $row['hotel'];
if (!\in_array($uid, $knownUids, true)) {
$skipped[] = $uid;
continue;
}
$accommodation = $accommodationByUid[$uid];
$column = $this->priceColumn($accommodation->getCurrency());
$type = self::OPTION_TYPE_MAP[(int) $row['type']] ?? AdditionalServiceType::Flat;
[$year, $label] = $this->stripYearPrefix(trim($row['title']));
$service = new AdditionalService();
$service->setLabel($label);
$service->setPrice($this->toCents($row[$column]));
$service->setType($type);
if (null !== $year) {
// Title carried its own year (e.g. "2026 Endreinigung ..."); scope validity to
// that accommodation's own price rows within that year, not the calendar year.
[$yearDateFrom, $yearDateTo] = $this->findYearPriceRange($accommodation, $year);
$service->setDateFrom($yearDateFrom ?? new \DateTimeImmutable($year.'-01-01'));
$service->setDateTo($yearDateTo ?? new \DateTimeImmutable($year.'-12-31'));
} else {
// dateFrom/dateTo filled in applyDateRangeToServices() once prices are known.
$service->setDateFrom(new \DateTimeImmutable('today'));
$service->setDateTo(new \DateTimeImmutable('today'));
$needsDefaultRange[] = $service;
}
$accommodation->addAdditionalService($service);
if (!$dryRun) {
$this->entityManager->persist($service);
}
++$count;
}
return [$count, $skipped, $needsDefaultRange];
}
/**
* Splits a leading "YYYY " prefix off a title, e.g. "2026 Endreinigung Küche" →
* [2026, "Endreinigung Küche"]. Returns [null, $label unchanged] when no such prefix
* is present.
*
* @return array{0: ?int, 1: string}
*/
private function stripYearPrefix(string $label): array
{
if (preg_match('/^(\d{4})\s+(.+)$/', $label, $matches)) {
return [(int) $matches[1], $matches[2]];
}
return [null, $label];
}
/**
* Finds the earliest dateFrom / latest dateTo among an accommodation's already-imported
* AccommodationPrice rows that fall within the given calendar year.
*
* @return array{0: ?\DateTimeImmutable, 1: ?\DateTimeImmutable}
*/
private function findYearPriceRange(Accommodation $accommodation, int $year): array
{
$dateFrom = null;
$dateTo = null;
foreach ($accommodation->getAccommodationPrices() as $price) {
if ((int) $price->getDateFrom()->format('Y') !== $year) {
continue;
}
if (null === $dateFrom || $price->getDateFrom() < $dateFrom) {
$dateFrom = $price->getDateFrom();
}
if (null === $dateTo || $price->getDateTo() > $dateTo) {
$dateTo = $price->getDateTo();
}
}
return [$dateFrom, $dateTo];
}
/**
* @param array<int, Accommodation> $accommodationByUid
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
* (titles without a leading year) get the accommodation-
* wide fallback range; year-prefixed ones already have
* their scoped range set in importAdditionalServices().
*/
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
{
$needsDefaultRange = new \SplObjectStorage();
foreach ($additionalServicesNeedingDefaultRange as $service) {
$needsDefaultRange->attach($service);
}
$seen = [];
foreach ($accommodationByUid as $accommodation) {
$id = spl_object_id($accommodation);
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$prices = $accommodation->getAccommodationPrices();
if ($prices->isEmpty()) {
continue;
}
$dateFrom = null;
$dateTo = null;
foreach ($prices as $price) {
if (null === $dateFrom || $price->getDateFrom() < $dateFrom) {
$dateFrom = $price->getDateFrom();
}
if (null === $dateTo || $price->getDateTo() > $dateTo) {
$dateTo = $price->getDateTo();
}
}
if (null === $dateFrom || null === $dateTo) {
continue;
}
foreach ($accommodation->getBoardServices() as $board) {
$board->setDateFrom($dateFrom);
$board->setDateTo($dateTo);
}
foreach ($accommodation->getAdditionalServices() as $service) {
if ($needsDefaultRange->contains($service)) {
$service->setDateFrom($dateFrom);
$service->setDateTo($dateTo);
}
}
}
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
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 CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route('/admin/accommodation-booking/create', name: 'app_admin_accommodationbooking_create')]
public function index(Request $request): Response
{
$booking = new AccommodationBooking();
$form = $this->createForm(AccommodationBookingType::class, $booking, [
'with_accommodation' => true,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($booking);
$this->entityManager->flush();
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('success', 'Die Buchung wurde erstellt');
$this->logger->info('Created accommodation booking', [
'id' => $booking->getId(),
'groupName' => $booking->getGroupName(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
}
return $this->render('admin/accommodation_booking/create.html.twig', [
'form' => $form,
]);
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
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 EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BoardServiceRepository $boardServiceRepo,
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route('/admin/accommodation-booking/{id}/edit', name: 'app_admin_accommodationbooking_edit')]
public function index(AccommodationBooking $booking, Request $request): Response
{
$accommodation = $booking->getAccommodation();
$boardServices = [];
$additionalServices = [];
$currentBoardService = null;
$currentAdditionalServices = [];
if (null !== $accommodation && null !== $booking->getDateFrom() && null !== $booking->getDateTo()) {
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$booking->getDateFrom(),
$booking->getDateTo(),
);
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$booking->getDateFrom(),
$booking->getDateTo(),
);
// Pre-select current board service if it is still in the available choices
foreach ($boardServices as $bs) {
if ($bs->getId() === $booking->getBoardServiceOriginalId()) {
$currentBoardService = $bs;
break;
}
}
// Pre-select current additional services that are still in the available choices
$currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId');
$currentAdditionalServices = array_values(array_filter(
$additionalServices,
fn(AdditionalService $s) => in_array($s->getId(), $currentIds, true),
));
}
$form = $this->createForm(AccommodationBookingType::class, $booking, [
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
'board_services' => $boardServices,
'additional_services' => $additionalServices,
'current_board_service' => $currentBoardService,
'current_additional_services' => $currentAdditionalServices,
]);
$wasInquiry = $booking->isInquiry();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($form->has('boardService')) {
$selectedBoardService = $form->get('boardService')->getData();
if ($selectedBoardService instanceof BoardService) {
$booking->setBoardServiceLabel($selectedBoardService->getLabel());
$booking->setBoardServicePrice($selectedBoardService->getPrice());
$booking->setBoardServiceOriginalId($selectedBoardService->getId());
} else {
$booking->setBoardServiceLabel(null);
$booking->setBoardServicePrice(null);
$booking->setBoardServiceOriginalId(null);
}
}
if ($form->has('selectedAdditionalServices')) {
$booking->setAdditionalServices([]);
foreach ($form->get('selectedAdditionalServices')->getData() as $service) {
$booking->addAdditionalServiceSnapshot(
$service->getLabel() ?? '',
$service->getPrice() ?? 0,
$service->getType(),
$service->getId(),
);
}
}
$this->entityManager->persist($booking);
$this->entityManager->flush();
if ($wasInquiry && !$booking->isInquiry()) {
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
}
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
$this->logger->info('Updated accommodation booking', [
'id' => $booking->getId(),
'groupName' => $booking->getGroupName(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
}
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
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 GenerateAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if ($request->isMethod(Request::METHOD_POST)) {
$this->bookingService->regenerateAccessLinkAndNotifyCustomer($booking);
$this->addFlash('success', 'Der Zugangslink wurde generiert und dem Kunden per E-Mail zugestellt.');
$this->logger->info('Generated accommodation booking access link', [
'id' => $booking->getId(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Repository\Groups\AccommodationBookingRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
public function index(Request $request): Response
{
$today = new \DateTimeImmutable('today');
$qb = $this
->bookingRepository
->createQueryBuilder('booking')
->leftJoin('booking.accommodation', 'accommodation')
->addSelect('accommodation')
->where('booking.dateTo >= :today')
->setParameter('today', $today)
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
'defaultSortFieldName' => 'booking.dateFrom',
'defaultSortDirection' => 'ASC',
]
);
return $this->render('admin/accommodation_booking/index.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ShowController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingLinkSigner $linkSigner,
) {
}
#[Route('/admin/accommodation-booking/{id}', name: 'app_admin_accommodationbooking_show')]
public function index(AccommodationBooking $booking, Request $request): Response
{
$hasAccessLink = null !== $booking->getAccessLinkIssuedAt();
return $this->render('admin/accommodation_booking/show.html.twig', [
'booking' => $booking,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
'returnUrl' => $this->getReturnUrl($request, 'app_admin_accommodationbooking'),
'accessLink' => $hasAccessLink ? $this->linkSigner->sign($booking) : null,
'accessLinkExpiresAt' => $hasAccessLink ? $this->linkSigner->expiresAt($booking) : null,
]);
}
}
@@ -47,9 +47,11 @@ class CreateController extends AbstractController
]
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/create.html.twig', [
@@ -44,7 +44,7 @@ class DeleteController extends AbstractController
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl);
return new HxRedirectResponse($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/modal_delete.html.twig', [
@@ -53,9 +53,11 @@ class EditController extends AbstractController
]
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/edit.html.twig', [
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Form\Admin\Groups\AdditionalServiceType;
@@ -17,8 +16,6 @@ use Symfony\Component\Routing\Attribute\Route;
class CreateController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -47,9 +44,11 @@ class CreateController extends AbstractController
'label' => $additionalService->getLabel(),
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/create.html.twig', [
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\AdditionalService;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
@@ -16,8 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
class DeleteController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -45,7 +42,7 @@ class DeleteController extends AbstractController
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl);
return new HxRedirectResponse($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/modal_delete.html.twig', [
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Form\Admin\Groups\AdditionalServiceType;
@@ -19,8 +18,6 @@ use Symfony\Component\Routing\Attribute\Route;
class EditController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -56,9 +53,11 @@ class EditController extends AbstractController
'label' => $additionalService->getLabel(),
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/edit.html.twig', [
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\Accommodation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SortingController extends AbstractController
{
use SortingControllerTrait;
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/admin/additional-service/sort/{id}', name: 'app_admin_additionalservice_sort', methods: ['POST'])]
public function index(Accommodation $accommodation, Request $request): Response
{
$this->applySorting($accommodation->getAdditionalServices(), $request->request->all('sorting'));
$this->entityManager->flush();
return new Response('', Response::HTTP_NO_CONTENT);
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\BoardServiceType;
@@ -17,7 +16,6 @@ use Symfony\Component\Routing\Attribute\Route;
class CreateController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -46,9 +44,11 @@ class CreateController extends AbstractController
'label' => $boardService->getLabel(),
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#board-services');
}
return $this->render('admin/board_service/create.html.twig', [
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\BoardService;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
@@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
class DeleteController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -44,7 +42,7 @@ class DeleteController extends AbstractController
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl);
return new HxRedirectResponse($returnUrl.'#board-services');
}
return $this->render('admin/board_service/modal_delete.html.twig', [
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\BoardServiceType;
use Doctrine\ORM\EntityManagerInterface;
@@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
class EditController extends AbstractController
{
use SortingControllerTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -52,9 +50,11 @@ class EditController extends AbstractController
'label' => $boardService->getLabel(),
]);
return $this->redirectToRoute('app_admin_accommodation_edit', [
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#board-services');
}
return $this->render('admin/board_service/edit.html.twig', [
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Controller\Traits\SortingControllerTrait;
use App\Entity\Groups\Accommodation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SortingController extends AbstractController
{
use SortingControllerTrait;
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/admin/board-service/sort/{id}', name: 'app_admin_boardservice_sort', methods: ['POST'])]
public function index(Accommodation $accommodation, Request $request): Response
{
$this->applySorting($accommodation->getBoardServices(), $request->request->all('sorting'));
$this->entityManager->flush();
return new Response('', Response::HTTP_NO_CONTENT);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Controller\Api;
use App\Entity\Groups\AccommodationBooking;
use App\Model\AccommodationBookingApiResponse;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\SerializerInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class AccommodationBookingController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly SerializerInterface $serializer,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/accommodation-bookings/{uuid}', name: 'api_accommodation_bookings_single', methods: ['GET'])]
public function single(string $uuid): JsonResponse
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
return $this->bookingResponse($booking);
}
#[Route(path: '/accommodation-bookings/{uuid}/accept', name: 'api_accommodation_bookings_accept', methods: ['POST'])]
public function accept(string $uuid): JsonResponse
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$this->bookingService->acceptBooking($booking);
return $this->bookingResponse($booking);
}
private function bookingResponse(AccommodationBooking $booking): JsonResponse
{
$response = new AccommodationBookingApiResponse($booking, $this->breakdownCalculator->compute($booking));
$json = $this->serializer->serialize($response, 'json', ['groups' => ['api:single']]);
return new JsonResponse($json, Response::HTTP_OK, [], true);
}
}
+1 -2
View File
@@ -114,7 +114,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, minPax: int|null, minNights: int|null}
* @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
{
@@ -144,7 +144,6 @@ class ContingentController extends AbstractController
'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
'currency' => $currency,
'includedPax' => $winner?->getIncludedPax(),
'minPax' => $winner?->getAccommodation()->getMinPaxSummer(),
'minNights' => $winner?->getMinNights(),
];
}
@@ -49,7 +49,7 @@ class IndexController extends AbstractController
path: '/bookings/create',
name: 'app_booking_create',
)]
public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response
public function index(#[MapQueryString] ?BookingQueryParams $params): Response
{
if (null === $params) {
throw $this->createNotFoundException('Invalid booking parameters provided');
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractAccommodationController extends AbstractController
{
protected function validateStepAccess(AccommodationBookingDto $dto, int $expectedStep): ?RedirectResponse
{
if ($expectedStep > $dto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($dto);
}
return null;
}
protected function createFailureResponse(\Throwable $exception, bool $htmx): Response
{
if ($htmx) {
return new Response('', 400);
}
$message = $exception instanceof AccommodationSessionNotFoundException
? 'Deine Sitzung ist abgelaufen. Bitte starte die Anfrage erneut.'
: 'Die Anfrage konnte nicht geladen werden.';
$this->addFlash('error', $message);
return $this->redirectToRoute('app_groups_booking_error');
}
private function redirectToCurrentStep(AccommodationBookingDto $dto): RedirectResponse
{
$route = match ($dto->currentStep) {
2 => 'app_groups_booking_step_2',
3 => 'app_groups_booking_step_3',
default => 'app_groups_booking_step_1',
};
return $this->redirectToRoute($route);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingQueryParams;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
/**
* Validates query params, initializes the session DTO, and redirects to step 1.
*
* No intermediate loading page is needed because all work here is DB-only and fast.
*/
#[Route('/groups/booking/init', name: 'app_groups_booking_init')]
public function init(Request $request, #[MapQueryString] ?AccommodationBookingQueryParams $params): Response
{
if (null === $params) {
$this->addFlash('error', 'Ungültige oder fehlende Parameter. Bitte überprüfe den Link.');
return $this->redirectToRoute('app_groups_booking_error');
}
try {
$this->sessionManager->clear($request);
$dto = $this->bookingService->initFromParams($params);
$this->sessionManager->save($request, $dto);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_error');
}
return $this->redirectToRoute('app_groups_booking_step_1');
}
#[Route('/groups/booking/success', name: 'app_groups_booking_success')]
public function success(Request $request): Response
{
$session = $request->getSession();
$resultType = $session instanceof FlashBagAwareSessionInterface
? $session->getFlashBag()->get('groups_booking_result')[0] ?? null
: null;
// Flash is consumed after the first read — reload/direct access ends up here with none.
if (null === $resultType) {
return $this->redirectToRoute('app_login');
}
return $this->render('groups/booking/success.html.twig', [
'resultType' => $resultType,
]);
}
#[Route('/groups/booking/error', name: 'app_groups_booking_error')]
public function error(): Response
{
return $this->render('groups/booking/error.html.twig');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OfferController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) {
$this->bookingService->acceptBooking($booking);
return new RedirectResponse($request->getUri());
}
return $this->render('groups/booking/offer.html.twig', [
'booking' => $booking,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
]);
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\AccommodationPrice;
use App\Exception\AccommodationSessionNotFoundException;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class Step1Controller extends AbstractAccommodationController
{
use HxTrait;
private const int CALENDAR_MONTHS = 18;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationPriceRepository $priceRepository,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly CacheInterface $cache,
private readonly CalendarGridBuilder $calendarGridBuilder,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-1', name: 'app_groups_booking_step_1', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($request->isMethod('POST')) {
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_step_1');
}
$dateFrom = $dto->dateFrom;
$dateTo = $dto->dateTo;
if (null !== $dateFrom && null !== $dateTo) {
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$dto->paxCount = $this->bookingService->computeInitialPaxCount($accommodation, $dateFrom, $dateTo);
}
// Reset service selections — no longer valid for the new date range
$dto->selectedBoardServiceId = null;
$dto->selectedAdditionalServiceIds = [];
$dto->currentStep = 2;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_2');
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/booking/step_1.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'hotelCode' => $accommodation->getCalendarCode() ?? '',
]);
}
#[Route('/groups/booking/calendar-refresh', name: 'app_groups_booking_calendar_refresh', methods: ['POST'])]
public function calendarRefresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
if ('' !== $dateFromRaw && '' !== $dateToRaw) {
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException) {
// Invalid dates — fall through with original dto state
}
} else {
$dto->dateFrom = null;
$dto->dateTo = null;
}
$this->sessionManager->save($request, $dto);
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
return new Response('', Response::HTTP_NOT_FOUND);
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->htmxOobResponse(
'groups/booking/_summary.html.twig',
['booking_summary'],
['dto' => $dto, 'ctx' => $ctx],
);
}
#[Route('/groups/booking/calendar-grid', name: 'app_groups_booking_calendar_grid', methods: ['GET'])]
public function calendarGrid(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
$hotelCode = $accommodation?->getCalendarCode() ?? '';
$now = new \DateTimeImmutable('today');
$calendarStart = $now->modify('first day of this month');
$calendarEnd = $calendarStart->modify('+'.(self::CALENDAR_MONTHS - 1).' months')->modify('last day of this month');
$todayStr = $now->format('Y-m-d');
$maxOffset = self::CALENDAR_MONTHS - 2;
if ($request->query->has('offset')) {
$offset = max(0, min($request->query->getInt('offset'), $maxOffset));
} else {
$offset = 0;
if ($dto->dateFrom !== null) {
$monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12
+ ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n'));
$offset = max(0, min($monthsDiff, $maxOffset));
}
}
$displayFrom = $calendarStart->modify("+{$offset} months");
$months = $this->calendarGridBuilder->buildMonths($displayFrom, 2);
$enrichedByDate = $this->buildEnrichedDayData(
$hotelCode,
$calendarStart,
$calendarEnd,
$calendarStart->format('Y-m-d'),
$calendarEnd->format('Y-m-d'),
);
return $this->render('groups/booking/_price_calendar_grid.html.twig', [
'months' => $months,
'enrichedByDate' => $enrichedByDate,
'todayStr' => $todayStr,
'offset' => $offset,
'totalMonths' => self::CALENDAR_MONTHS,
]);
}
/**
* Fetches contingent + price data and returns a map of date → ['status', 'minNights'].
*
* Returns an empty array on API failure; the template treats missing dates as blocked.
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
string $dateFromStr,
string $dateToStr,
): array {
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
$calendar = $this->cache->get(
$cacheKey,
function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr);
},
);
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
return [];
}
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$availableDates = [];
foreach ($calendar->data as $entry) {
if (ContingentStatus::Ok === $entry->status) {
$availableDates[$entry->date] = true;
}
}
$enriched = [];
foreach ($calendar->data as $entry) {
$isAvailable = ContingentStatus::Ok === $entry->status;
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
$prevAvailable = isset($availableDates[$prevDate]);
$status = match (true) {
$isAvailable && $prevAvailable => 'ok',
$isAvailable => 'blocked-to-ok',
$prevAvailable => 'checkout-only',
default => 'blocked',
};
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
$enriched[$entry->date] = [
'status' => $status,
'minNights' => $winner?->getMinNights() ?? 0,
];
}
return $enriched;
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep2Type;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\GroupsPriceCalculator;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step2Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-2', name: 'app_groups_booking_step_2')]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($redirect = $this->validateStepAccess($dto, 2)) {
return $redirect;
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, true);
if ($form->isSubmitted() && $form->isValid()) {
$dto->currentStep = 3;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_3');
}
return $this->render('groups/booking/step_2.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
#[Route('/groups/booking/step-2/refresh', name: 'app_groups_booking_step_2_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, true);
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, false);
return $this->htmxOobResponse(
'groups/booking/step_2.html.twig',
['accommodation_form', 'accommodation_summary'],
['dto' => $dto, 'ctx' => $ctx, 'form' => $form->createView()],
);
}
/**
* @return array{AccommodationBookingContext, FormInterface<AccommodationBookingDto>}
*/
private function buildStep2Context(Request $request, AccommodationBookingDto $dto, bool $validate): array
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
'validation_groups' => $validate ? ['step_2'] : false,
]);
$form->handleRequest($request);
$status = $this->bookingService->computeInquiryStatus($dto, $prices);
$dto->isInquiry = $status->isInquiry;
$dto->inquiryReasons = $status->reasons;
$boardService = $this->resolveSelectedBoardService($dto, $services['boardServices']);
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
$boardService,
$selectedAdditionalServices,
$accommodation->getCurrency() ?? 'EUR',
);
$dto->totalPrice = $priceBreakdown['total'];
$dto->priceBreakdown = $priceBreakdown;
}
$this->sessionManager->save($request, $dto);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
groupedAdditionalServices: $services['groupedAdditionalServices'],
ungroupedAdditionalServices: $services['ungroupedAdditionalServices'],
priceBreakdown: $priceBreakdown,
);
return [$ctx, $form];
}
/**
* @param BoardService[] $boardServices
*/
private function resolveSelectedBoardService(AccommodationBookingDto $dto, array $boardServices): ?BoardService
{
if (null === $dto->selectedBoardServiceId) {
return null;
}
foreach ($boardServices as $service) {
if ($service->getId() === $dto->selectedBoardServiceId) {
return $service;
}
}
return null;
}
/**
* @param AdditionalService[] $additionalServices
*
* @return AdditionalService[]
*/
private function resolveSelectedAdditionalServices(AccommodationBookingDto $dto, array $additionalServices): array
{
if (empty($dto->selectedAdditionalServiceIds)) {
return [];
}
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
return array_values(array_filter(
$additionalServices,
fn(AdditionalService $s) => isset($selectedIds[$s->getId()]),
));
}
/**
* Builds an ID-keyed choices array for Symfony form binding.
* Labels and price formatting are handled in Twig.
*
* @param BoardService[]|AdditionalService[] $services
*
* @return array<string, int>
*/
private function buildServiceChoices(array $services): array
{
$choices = [];
foreach ($services as $service) {
$choices[(string) $service->getId()] = $service->getId();
}
return $choices;
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep3ConfirmationType;
use App\Form\AccommodationStep3Type;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\Form\ClickableInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step3Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')]
public function index(Request $request): Response
{
$dto = $this->resolveDto($request, false);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
priceBreakdown: $dto->priceBreakdown ?: null,
);
$form = $this->createForm(AccommodationStep3Type::class, $dto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$submitBookingClicked = false;
// booking or inquiry status depends on clicked button
if (true === $form->has('submitBooking')) {
$submitBookingButton = $form->get('submitBooking');
$submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked();
}
// Booking is only offered via the confirmation modal; a forced-inquiry DTO
// can never actually reach this branch since submitBooking is disabled for it.
if (true === $submitBookingClicked && false === $dto->isInquiry) {
$dto->forceInquiry = false;
$this->sessionManager->save($request, $dto);
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
return $this->render('groups/booking/step_3.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
'confirmationForm' => $confirmationForm,
]);
}
$dto->forceInquiry = true;
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->redirectToRoute('app_groups_booking_success');
}
return $this->render('groups/booking/step_3.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
#[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])]
public function confirm(Request $request): Response
{
$dto = $this->resolveDto($request, true);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
}
return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [
'confirmationForm' => $confirmationForm,
]);
}
/**
* Loads the session DTO and checks step access, returning an early
* response (session failure or step-access redirect) if either fails.
*/
private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, $htmx);
}
if ($redirect = $this->validateStepAccess($dto, 3)) {
return $redirect;
}
return $dto;
}
private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
return $accommodation;
}
/**
* @param array{boardServices: array, additionalServices: array} $services
*/
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->persist($dto, $accommodation, $prices, $services);
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown);
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
$this->sessionManager->clear($request);
}
}
@@ -1,18 +0,0 @@
<?php
namespace App\Controller\Traits;
use App\Entity\Groups\SortableEntityInterface;
trait SortingControllerTrait
{
private function applySorting(iterable $collection, array $sortingMap): void
{
foreach ($collection as $entity) {
/** @var SortableEntityInterface $entity */
if (isset($sortingMap[$entity->getId()])) {
$entity->setSorting((int) $sortingMap[$entity->getId()]);
}
}
}
}
+2 -18
View File
@@ -34,10 +34,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
#[ORM\Column(length: 16, nullable: true)]
private ?string $cmsCode = null;
#[ORM\Column]
#[Assert\NotNull(message: 'required')]
private ?int $minPaxSummer = null;
/**
* @var Collection<int, AccommodationPrice>
*/
@@ -49,14 +45,14 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
* @var Collection<int, BoardService>
*/
#[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])]
#[ORM\OrderBy(['sorting' => 'ASC'])]
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
private Collection $boardServices;
/**
* @var Collection<int, AdditionalService>
*/
#[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])]
#[ORM\OrderBy(['sorting' => 'ASC'])]
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
private Collection $additionalServices;
#[ORM\Column]
@@ -121,18 +117,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
return $this->cmsCode ?? $this->calendarCode;
}
public function getMinPaxSummer(): ?int
{
return $this->minPaxSummer;
}
public function setMinPaxSummer(int $minPaxSummer): self
{
$this->minPaxSummer = $minPaxSummer;
return $this;
}
/**
* @return Collection<int, AccommodationPrice>
*/
+429
View File
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Entity\BlameableEntity;
use App\Entity\BlameableEntityInterface;
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\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accommodation $accommodation = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column]
private int $paxCount = 0;
#[ORM\Column]
private int $minorsCount = 0;
#[ORM\Column]
private int $childrenCount = 0;
#[ORM\Column(length: 255, nullable: true)]
private ?string $boardServiceLabel = null;
#[ORM\Column(nullable: true)]
private ?int $boardServicePrice = null;
#[ORM\Column(nullable: true)]
private ?int $boardServiceOriginalId = null;
#[ORM\Column(type: Types::JSON)]
private array $additionalServices = [];
#[ORM\Column]
private bool $isInquiry = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $accessLinkIssuedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $acceptedAt = null;
#[ORM\Column(length: 255)]
private ?string $groupName = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $salutation = null;
#[ORM\Column(length: 100)]
private ?string $firstName = null;
#[ORM\Column(length: 100)]
private ?string $lastName = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $zip = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $city = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $remarks = null;
#[ORM\Column(nullable: true)]
#[Assert\Positive(message: 'invalid')]
private ?int $discount = null;
public function __construct()
{
$this->uuid = Uuid::v4()->toRfc4122();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getAccommodation(): ?Accommodation
{
return $this->accommodation;
}
public function setAccommodation(?Accommodation $accommodation): self
{
$this->accommodation = $accommodation;
return $this;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(\DateTimeImmutable $dateFrom): self
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(\DateTimeImmutable $dateTo): self
{
$this->dateTo = $dateTo;
return $this;
}
public function getNights(): int
{
if (null === $this->dateFrom || null === $this->dateTo) {
return 0;
}
return $this->dateFrom->diff($this->dateTo)->days;
}
public function getPaxCount(): int
{
return $this->paxCount;
}
public function setPaxCount(int $paxCount): self
{
$this->paxCount = $paxCount;
return $this;
}
public function getMinorsCount(): int
{
return $this->minorsCount;
}
public function setMinorsCount(int $minorsCount): self
{
$this->minorsCount = $minorsCount;
return $this;
}
public function getChildrenCount(): int
{
return $this->childrenCount;
}
public function setChildrenCount(int $childrenCount): self
{
$this->childrenCount = $childrenCount;
return $this;
}
public function getBoardServiceLabel(): ?string
{
return $this->boardServiceLabel;
}
public function setBoardServiceLabel(?string $boardServiceLabel): self
{
$this->boardServiceLabel = $boardServiceLabel;
return $this;
}
public function getBoardServicePrice(): ?int
{
return $this->boardServicePrice;
}
public function setBoardServicePrice(?int $boardServicePrice): self
{
$this->boardServicePrice = $boardServicePrice;
return $this;
}
public function getBoardServiceOriginalId(): ?int
{
return $this->boardServiceOriginalId;
}
public function setBoardServiceOriginalId(?int $boardServiceOriginalId): self
{
$this->boardServiceOriginalId = $boardServiceOriginalId;
return $this;
}
public function getAdditionalServices(): array
{
return $this->additionalServices;
}
public function setAdditionalServices(array $additionalServices): self
{
$this->additionalServices = $additionalServices;
return $this;
}
public function addAdditionalServiceSnapshot(string $label, int $price, AdditionalServiceType|string $type, ?int $originalServiceId): self
{
$this->additionalServices[] = [
'label' => $label,
'price' => $price,
'type' => $type instanceof AdditionalServiceType ? $type->value : $type,
'originalServiceId' => $originalServiceId,
];
return $this;
}
public function isInquiry(): bool
{
return $this->isInquiry;
}
public function setIsInquiry(bool $isInquiry): self
{
$this->isInquiry = $isInquiry;
return $this;
}
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
{
return $this->accessLinkIssuedAt;
}
public function setAccessLinkIssuedAt(?\DateTimeImmutable $accessLinkIssuedAt): self
{
$this->accessLinkIssuedAt = $accessLinkIssuedAt;
return $this;
}
public function getAcceptedAt(): ?\DateTimeImmutable
{
return $this->acceptedAt;
}
public function setAcceptedAt(?\DateTimeImmutable $acceptedAt): self
{
$this->acceptedAt = $acceptedAt;
return $this;
}
public function getGroupName(): ?string
{
return $this->groupName;
}
public function setGroupName(string $groupName): self
{
$this->groupName = $groupName;
return $this;
}
public function getSalutation(): ?string
{
return $this->salutation;
}
public function setSalutation(?string $salutation): self
{
$this->salutation = $salutation;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): self
{
$this->street = $street;
return $this;
}
public function getZip(): ?string
{
return $this->zip;
}
public function setZip(?string $zip): self
{
$this->zip = $zip;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): self
{
$this->city = $city;
return $this;
}
public function getRemarks(): ?string
{
return $this->remarks;
}
public function setRemarks(?string $remarks): self
{
$this->remarks = $remarks;
return $this;
}
public function getDiscount(): ?int
{
return $this->discount;
}
public function setDiscount(?int $discount): self
{
$this->discount = $discount;
return $this;
}
}
+17 -1
View File
@@ -32,7 +32,8 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
#[Assert\NotNull(message: 'required')]
private ?\DateTimeImmutable $dateFrom = null;
// Inclusive: the last night this price applies. A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.); 10.01. is checkout.
// Inclusive: the last night this price applies.
// A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.), 10.01. is checkout.
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'required')]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')]
@@ -63,6 +64,9 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
#[ORM\Column]
private bool $acceptUndersubscription = false;
#[ORM\Column]
private bool $acceptShortTerm = false;
public function __clone()
{
$this->id = null;
@@ -192,4 +196,16 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
return $this;
}
public function isAcceptShortTerm(): bool
{
return $this->acceptShortTerm;
}
public function setAcceptShortTerm(bool $acceptShortTerm): self
{
$this->acceptShortTerm = $acceptShortTerm;
return $this;
}
}
+22 -16
View File
@@ -10,9 +10,10 @@ use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AdditionalServiceRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AdditionalServiceRepository::class)]
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
@@ -27,26 +28,31 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
private ?Accommodation $accommodation = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'required')]
private ?string $label = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column]
#[Assert\NotNull(message: 'required')]
private ?int $price = null;
#[ORM\Column(length: 20, enumType: AdditionalServiceType::class)]
#[Assert\NotNull(message: 'required')]
private ?AdditionalServiceType $type = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'required')]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'required')]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column(length: 128, nullable: true)]
private ?string $selectionGroup = null;
#[ORM\Column]
private ?int $sorting = 0;
public function __clone()
{
$this->id = null;
@@ -81,6 +87,18 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getPrice(): ?int
{
return $this->price;
@@ -140,16 +158,4 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
return $this;
}
public function getSorting(): ?int
{
return $this->sorting;
}
public function setSorting(?int $sorting): self
{
$this->sorting = $sorting;
return $this;
}
}
+21 -16
View File
@@ -9,9 +9,10 @@ use App\Entity\TimestampableEntityInterface;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BoardServiceRepository::class)]
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
@@ -26,20 +27,24 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
private ?Accommodation $accommodation = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'required')]
private ?string $label = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column]
#[Assert\NotNull(message: 'required')]
private ?int $price = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'required')]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'required')]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column]
private ?int $sorting = 0;
public function __clone()
{
$this->id = null;
@@ -74,6 +79,18 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getPrice(): ?int
{
return $this->price;
@@ -109,16 +126,4 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
return $this;
}
public function getSorting(): ?int
{
return $this->sorting;
}
public function setSorting(?int $sorting): self
{
$this->sorting = $sorting;
return $this;
}
}
@@ -1,10 +0,0 @@
<?php
namespace App\Entity\Groups;
interface SortableEntityInterface
{
public function getId(): ?int;
public function setSorting(?int $sorting): self;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;
class AccommodationSessionNotFoundException extends HttpException
{
public function __construct(?\Throwable $previous = null)
{
parent::__construct(404, 'No valid accommodation inquiry session found.', $previous);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationStep2Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('paxCount', IntegerType::class, [
'label' => 'Anzahl Personen',
'attr' => ['min' => 1],
])
->add('minorsCount', IntegerType::class, [
'label' => 'davon Kinder (0–3 Jahre)',
'required' => false,
'attr' => ['min' => 0],
])
->add('childrenCount', IntegerType::class, [
'label' => sprintf('davon Kinder (4–%d Jahre)', $options['max_adolescent_age']),
'required' => false,
'attr' => ['min' => 0],
])
->add('selectedBoardServiceId', ChoiceType::class, [
'label' => false,
'choices' => $options['board_service_choices'],
'expanded' => true,
'multiple' => false,
'required' => false,
'placeholder' => 'Selbstversorgung',
])
->add('selectedAdditionalServiceIds', ChoiceType::class, [
'label' => false,
'choices' => $options['additional_service_choices'],
'multiple' => true,
'expanded' => false,
'required' => false,
])
;
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
if (!is_array($data)) {
return;
}
foreach (['minorsCount', 'childrenCount'] as $field) {
if (!isset($data[$field]) || '' === $data[$field]) {
$data[$field] = 0;
}
}
// Board service rendered manually: empty string from the "Selbstversorgung" radio → null
if (isset($data['selectedBoardServiceId']) && '' === $data['selectedBoardServiceId']) {
unset($data['selectedBoardServiceId']);
}
$event->setData($data);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'validation_groups' => ['step_2'],
'board_service_choices' => [],
'additional_service_choices' => [],
'max_adolescent_age' => 0,
]);
$resolver->setAllowedTypes('max_adolescent_age', 'int');
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
/**
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationStep3ConfirmationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLink = sprintf(
'<a href="%s" target="_blank" rel="noopener" class="underline">allgemeinen Geschäftsbedingungen (AGB)</a>',
htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8')
);
$builder->add('termsAccepted', CheckboxType::class, [
'label' => sprintf('Durch Anklicken des Absenden-Buttons bestätige ich, dass ich die %s gelesen habe und damit einverstanden bin, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
'label_html' => true,
'mapped' => false,
'required' => true,
'constraints' => [
new IsTrue(message: 'Bitte akzeptiere die AGB, um fortzufahren.'),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'terms_url' => 'https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/',
]);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationStep3Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('remarks', TextareaType::class, [
'label' => false,
'required' => false,
'attr' => ['rows' => 4],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'validation_groups' => ['step_3'],
]);
}
}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Range;
class AccommodationBookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if ($options['with_accommodation']) {
$builder->add('accommodation', EntityType::class, [
'class' => Accommodation::class,
'choice_label' => 'name',
'label' => 'Gruppenhaus',
]);
}
$builder
->add('groupName', TextType::class, [
'label' => 'Gruppenname',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'required' => false,
'placeholder' => '',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
'required' => false,
])
->add('street', TextType::class, [
'label' => 'Straße',
'required' => false,
])
->add('zip', TextType::class, [
'label' => 'PLZ',
'required' => false,
])
->add('city', TextType::class, [
'label' => 'Ort',
'required' => false,
])
->add('dateFrom', DateType::class, [
'label' => 'Anreise',
'widget' => 'single_text',
])
->add('dateTo', DateType::class, [
'label' => 'Abreise',
'widget' => 'single_text',
])
->add('paxCount', IntegerType::class, [
'label' => 'Anzahl Personen',
])
->add('minorsCount', IntegerType::class, [
'label' => 'davon Kinder (0–3 Jahre)',
])
->add('childrenCount', IntegerType::class, [
'label' => $options['max_adolescent_age'] > 0
? sprintf('davon Kinder (4–%d Jahre)', $options['max_adolescent_age'])
: 'davon Kinder',
])
;
if (!empty($options['board_services'])) {
$builder->add('boardService', EntityType::class, [
'class' => BoardService::class,
'mapped' => false,
'required' => false,
'choices' => $options['board_services'],
'choice_label' => 'label',
'placeholder' => 'keine',
'label' => 'Verpflegung',
'data' => $options['current_board_service'],
]);
}
if (!empty($options['additional_services'])) {
$builder->add('selectedAdditionalServices', EntityType::class, [
'class' => AdditionalService::class,
'mapped' => false,
'required' => false,
'multiple' => true,
'expanded' => true,
'choices' => $options['additional_services'],
'choice_label' => 'label',
'label' => 'Zusatzleistungen',
'data' => $options['current_additional_services'],
]);
}
$builder
->add('isInquiry', CheckboxType::class, [
'label' => 'Anfrage (nicht bindend)',
'required' => false,
])
->add('discount', IntegerType::class, [
'label' => 'Rabatt (%)',
'required' => false,
'constraints' => [
new Range(min: 1, max: 100),
],
])
->add('remarks', TextareaType::class, [
'label' => 'Bemerkungen',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
'with_accommodation' => false,
'max_adolescent_age' => 0,
'board_services' => [],
'additional_services' => [],
'current_board_service' => null,
'current_additional_services' => [],
]);
$resolver->setAllowedTypes('with_accommodation', 'bool');
$resolver->setAllowedTypes('max_adolescent_age', 'int');
$resolver->setAllowedTypes('board_services', 'array');
$resolver->setAllowedTypes('additional_services', 'array');
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
$resolver->setAllowedTypes('current_additional_services', 'array');
}
}
@@ -7,9 +7,9 @@ namespace App\Form\Admin\Groups;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\PriceType;
use App\Enum\Groups\Season;
use App\Form\DatepickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
@@ -21,12 +21,12 @@ class AccommodationPriceType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dateFrom', DatepickerType::class, [
->add('dateFrom', DateType::class, [
'label' => 'Datum von',
'html5' => true,
'widget' => 'single_text',
])
->add('dateTo', DatepickerType::class, [
->add('dateTo', DateType::class, [
'label' => 'Datum bis',
'html5' => true,
'widget' => 'single_text',
@@ -63,6 +63,10 @@ class AccommodationPriceType extends AbstractType
'label' => 'Unterbelegung akzeptieren',
'required' => false,
])
->add('acceptShortTerm', CheckboxType::class, [
'label' => 'Kurzzeit-Belegung akzeptieren',
'required' => false,
])
;
}
+1 -4
View File
@@ -27,11 +27,8 @@ class AccommodationType extends AbstractType
'label' => 'Code für CMS-Daten',
'required' => false,
])
->add('minPaxSummer', IntegerType::class, [
'label' => 'Mindestbelegung Sommer',
])
->add('maxAdolescentAge', IntegerType::class, [
'label' => 'Altersgrenze Jugendliche',
'label' => 'Altersgrenze Kinder',
])
->add('currency', ChoiceType::class, [
'label' => 'Währung',
@@ -6,10 +6,11 @@ namespace App\Form\Admin\Groups;
use App\Entity\Groups\AdditionalService;
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
use App\Form\DatepickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -22,6 +23,13 @@ class AdditionalServiceType extends AbstractType
->add('label', TextType::class, [
'label' => 'Bezeichnung',
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => [
'rows' => 2,
],
])
->add('type', EnumType::class, [
'label' => 'Preistyp',
'class' => AdditionalServiceTypeEnum::class,
@@ -35,13 +43,14 @@ class AdditionalServiceType extends AbstractType
->add('selectionGroup', TextType::class, [
'label' => 'Auswahlgruppe',
'required' => false,
'disabled' => true,
])
->add('dateFrom', DatepickerType::class, [
->add('dateFrom', DateType::class, [
'label' => 'Datum von',
'html5' => true,
'widget' => 'single_text',
])
->add('dateTo', DatepickerType::class, [
->add('dateTo', DateType::class, [
'label' => 'Datum bis',
'html5' => true,
'widget' => 'single_text',
+11 -3
View File
@@ -5,9 +5,10 @@ declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Entity\Groups\BoardService;
use App\Form\DatepickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -20,17 +21,24 @@ class BoardServiceType extends AbstractType
->add('label', TextType::class, [
'label' => 'Bezeichnung',
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => [
'rows' => 2,
],
])
->add('price', MoneyType::class, [
'label' => 'Preis pro Person/Nacht',
'currency' => $options['currency'],
'divisor' => 100,
])
->add('dateFrom', DatepickerType::class, [
->add('dateFrom', DateType::class, [
'label' => 'Datum von',
'html5' => true,
'widget' => 'single_text',
])
->add('dateTo', DatepickerType::class, [
->add('dateTo', DateType::class, [
'label' => 'Datum bis',
'html5' => true,
'widget' => 'single_text',
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
class AccommodationBookingDto
{
public int $currentStep = 1;
public ?int $accommodationId = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
#[Assert\Positive(message: 'invalid', groups: ['step_3'])]
public int $paxCount = 1;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
public int $minorsCount = 0;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
public int $childrenCount = 0;
public ?int $selectedBoardServiceId = null;
/** @var list<int>*/
public array $selectedAdditionalServiceIds = [];
public bool $isInquiry = false;
/** @var string[] */
public array $inquiryReasons = [];
public bool $forceInquiry = false;
public ?int $totalPrice = null;
/** @var array<string, mixed> */
public array $priceBreakdown = [];
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $groupName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])]
public ?string $salutation = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $firstName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $lastName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\Email(message: 'invalid', groups: ['step_3'])]
public ?string $email = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $phone = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $street = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $zip = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
public ?string $city = null;
public ?string $remarks = null;
public function getNights(): int
{
if (null === $this->dateFrom || null === $this->dateTo) {
return 0;
}
return $this->dateFrom->diff($this->dateTo)->days;
}
}
+15 -5
View File
@@ -32,13 +32,14 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
],
]);
$menu->addChild('Benutzer', [
'route' => 'app_admin_user',
$menu->addChild('Guppenbuchungen', [
'route' => 'app_admin_accommodationbooking',
'linkAttributes' => [
'title' => 'Benutzer',
'title' => 'Buchungen',
],
'extras' => [
'icon' => 'users',
'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
],
]);
$menu->addChild('Gruppenhäuser', [
@@ -48,7 +49,16 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
'extras' => [
'icon' => 'house',
'routes' => [['pattern' => '/^app_admin_accommodation/']],
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
],
]);
$menu->addChild('Benutzer', [
'route' => 'app_admin_user',
'linkAttributes' => [
'title' => 'Benutzer',
],
'extras' => [
'icon' => 'users',
],
]);
$menu->addChild('Logs', [
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class AccommodationBookingAdditionalServiceItem
{
#[Groups(['api:single'])]
public string $label;
#[Groups(['api:single'])]
public int $price;
#[Groups(['api:single'])]
public string $type;
/**
* @param array{label?: string, price?: int, type?: string, originalServiceId?: int|null} $snapshot
*/
public function __construct(array $snapshot)
{
$this->label = $snapshot['label'] ?? '';
$this->price = $snapshot['price'] ?? 0;
$this->type = $snapshot['type'] ?? '';
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
final readonly class AccommodationBookingApiResponse
{
#[Groups(['api:single'])]
public string $uuid;
#[Groups(['api:single'])]
public string $status;
#[Groups(['api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom;
#[Groups(['api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateTo;
#[Groups(['api:single'])]
public int $nights;
#[Groups(['api:single'])]
public int $paxCount;
#[Groups(['api:single'])]
public int $minorsCount;
#[Groups(['api:single'])]
public int $childrenCount;
#[Groups(['api:single'])]
public ?string $groupName;
#[Groups(['api:single'])]
public ?\DateTimeImmutable $acceptedAt;
#[Groups(['api:single'])]
public AccommodationBookingPersonalData $personalData;
#[Groups(['api:single'])]
public AccommodationBookingHotelInfo $accommodation;
#[Groups(['api:single'])]
public AccommodationBookingBoardServiceInfo $boardService;
/**
* @var list<AccommodationBookingAdditionalServiceItem>
*/
#[Groups(['api:single'])]
public array $additionalServices;
#[Groups(['api:single'])]
public ?int $discount;
/**
* @var array<string, mixed>|null
*/
#[Groups(['api:single'])]
public ?array $priceBreakdown;
/**
* @param array<string, mixed>|null $priceBreakdown
*/
public function __construct(AccommodationBooking $booking, ?array $priceBreakdown)
{
$this->uuid = $booking->getUuid();
$this->status = $booking->isInquiry() ? 'inquiry' : 'booking';
$this->dateFrom = $booking->getDateFrom();
$this->dateTo = $booking->getDateTo();
$this->nights = $booking->getNights();
$this->paxCount = $booking->getPaxCount();
$this->minorsCount = $booking->getMinorsCount();
$this->childrenCount = $booking->getChildrenCount();
$this->groupName = $booking->getGroupName();
$this->acceptedAt = $booking->getAcceptedAt();
$this->personalData = new AccommodationBookingPersonalData($booking);
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
$this->additionalServices = array_map(
static fn (array $service) => new AccommodationBookingAdditionalServiceItem($service),
$booking->getAdditionalServices(),
);
$this->discount = $booking->getDiscount();
$this->priceBreakdown = $priceBreakdown;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class AccommodationBookingBoardServiceInfo
{
#[Groups(['api:single'])]
public ?string $label;
#[Groups(['api:single'])]
public ?int $price;
public function __construct(AccommodationBooking $booking)
{
$this->label = $booking->getBoardServiceLabel();
$this->price = $booking->getBoardServicePrice();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
final readonly class AccommodationBookingContext
{
/**
* @param BoardService[] $boardServices
* @param AdditionalService[] $additionalServices
* @param array<string, AdditionalService[]> $groupedAdditionalServices
* @param AdditionalService[] $ungroupedAdditionalServices
* @param array<string, mixed>|null $priceBreakdown
* @param array<string, mixed>|null $hotelCmsData
*/
public function __construct(
public Accommodation $accommodation,
public ?array $hotelCmsData = null,
public array $boardServices = [],
public array $additionalServices = [],
public array $groupedAdditionalServices = [],
public array $ungroupedAdditionalServices = [],
public ?array $priceBreakdown = null,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\Accommodation;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class AccommodationBookingHotelInfo
{
#[Groups(['api:single'])]
public ?string $calendarCode;
#[Groups(['api:single'])]
public ?string $cmsCode;
public function __construct(?Accommodation $accommodation)
{
$this->calendarCode = $accommodation?->getCalendarCode();
$this->cmsCode = $accommodation?->getCmsCode();
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Serializer\Attribute\Groups;
final readonly class AccommodationBookingPersonalData
{
#[Groups(['api:single'])]
public ?string $salutation;
#[Groups(['api:single'])]
public ?string $firstName;
#[Groups(['api:single'])]
public ?string $lastName;
#[Groups(['api:single'])]
public ?string $email;
#[Groups(['api:single'])]
public ?string $phone;
#[Groups(['api:single'])]
public ?string $street;
#[Groups(['api:single'])]
public ?string $zip;
#[Groups(['api:single'])]
public ?string $city;
#[Groups(['api:single'])]
public ?string $remarks;
public function __construct(AccommodationBooking $booking)
{
$this->salutation = $booking->getSalutation();
$this->firstName = $booking->getFirstName();
$this->lastName = $booking->getLastName();
$this->email = $booking->getEmail();
$this->phone = $booking->getPhone();
$this->street = $booking->getStreet();
$this->zip = $booking->getZip();
$this->city = $booking->getCity();
$this->remarks = $booking->getRemarks();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
readonly class AccommodationBookingQueryParams
{
public function __construct(
#[SerializedName('hotel_code')]
#[Assert\NotBlank]
public string $hotelCode,
#[SerializedName('date_from')]
public ?string $dateFrom = null,
#[SerializedName('date_to')]
public ?string $dateTo = null,
) {
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class InquiryStatus
{
/** @param string[] $reasons */
public function __construct(
public bool $isInquiry,
public array $reasons,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\AccommodationBooking;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccommodationBooking>
*/
class AccommodationBookingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccommodationBooking::class);
}
}
@@ -40,4 +40,24 @@ class AccommodationRepository extends ServiceEntityRepository
// ->getOneOrNullResult()
// ;
// }
public function findOneByCmsCode(string $cmsCode): ?Accommodation
{
return $this->createQueryBuilder('a')
->andWhere('a.cmsCode = :cmsCode')
->setParameter('cmsCode', $cmsCode)
->getQuery()
->getOneOrNullResult()
;
}
public function findOneByCalendarCode(string $calendarCode): ?Accommodation
{
return $this->createQueryBuilder('a')
->andWhere('a.calendarCode = :calendarCode')
->setParameter('calendarCode', $calendarCode)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\AdditionalService;
@@ -11,6 +13,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class AdditionalServiceRepository extends ServiceEntityRepository
{
use FindsByAccommodationAndDateRangeTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AdditionalService::class);
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\BoardService;
@@ -11,6 +13,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class BoardServiceRepository extends ServiceEntityRepository
{
use FindsByAccommodationAndDateRangeTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BoardService::class);
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
trait FindsByAccommodationAndDateRangeTrait
{
/**
* Returns all records active during the given date range.
*
* Overlap condition: entity.dateFrom <= dateTo AND entity.dateTo >= dateFrom
*/
public function findByAccommodationAndDateRange(
Accommodation $accommodation,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): array {
return $this->createQueryBuilder('s')
->where('s.accommodation = :accommodation')
->andWhere('s.dateFrom <= :dateTo')
->andWhere('s.dateTo >= :dateFrom')
->setParameter('accommodation', $accommodation)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->orderBy('s.dateFrom', 'ASC')
->addOrderBy('s.label', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationBooking;
use App\Repository\Groups\AccommodationPriceRepository;
class AccommodationBookingBreakdownCalculator
{
public function __construct(
private readonly GroupsPriceCalculator $priceCalculator,
private readonly AccommodationPriceRepository $priceRepo,
) {
}
/**
* Recomputes the price breakdown from the booking's frozen snapshot data and current
* accommodation prices for the same date range.
*
* @return array<string, mixed>|null null when no accommodation or dates are set
*/
public function compute(AccommodationBooking $booking): ?array
{
$accommodation = $booking->getAccommodation();
$dateFrom = $booking->getDateFrom();
$dateTo = $booking->getDateTo();
if (null === $accommodation || null === $dateFrom || null === $dateTo) {
return null;
}
$prices = $this->priceRepo->findByHotelCodeAndDateRange(
$accommodation->getCalendarCode() ?? '',
$dateFrom,
$dateTo,
);
return $this->priceCalculator->calculateFromSnapshots(
$booking->getPaxCount(),
$booking->getMinorsCount(),
$booking->getNights(),
$dateFrom,
$dateTo,
$prices,
$booking->getBoardServicePrice(),
$booking->getAdditionalServices(),
$accommodation->getCurrency(),
);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AccommodationBookingLinkSigner
{
private const int LINK_TTL_DAYS = 90;
private const string TIMESTAMP_PARAM = 't';
private readonly UriSigner $uriSigner;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
#[Autowire(env: 'ACCOMMODATION_OFFER_LINK_SECRET')]
string $secret,
) {
$this->uriSigner = new UriSigner($secret);
}
public function sign(AccommodationBooking $booking): string
{
$issuedAt = $booking->getAccessLinkIssuedAt();
if (null === $issuedAt) {
throw new \LogicException('Cannot sign an access link before accessLinkIssuedAt is set.');
}
$url = $this->urlGenerator->generate(
'app_groups_booking_offer',
['uuid' => $booking->getUuid(), self::TIMESTAMP_PARAM => $issuedAt->getTimestamp()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
return $this->uriSigner->sign($url);
}
public function expiresAt(AccommodationBooking $booking): ?\DateTimeImmutable
{
return $booking->getAccessLinkIssuedAt()?->modify(sprintf('+%d days', self::LINK_TTL_DAYS));
}
public function isValidLinkRequest(Request $request, AccommodationBooking $booking): bool
{
$issuedAt = $booking->getAccessLinkIssuedAt();
if (null === $issuedAt) {
return false;
}
if (!$this->uriSigner->checkRequest($request)) {
return false;
}
$timestampParam = $request->query->get(self::TIMESTAMP_PARAM);
if (null === $timestampParam || (int) $timestampParam !== $issuedAt->getTimestamp()) {
return false;
}
$expiresAt = $this->expiresAt($booking);
return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt;
}
}
+429
View File
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\InquiryStatus;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class AccommodationBookingService
{
public function __construct(
private readonly AccommodationRepository $accommodationRepo,
private readonly AccommodationPriceRepository $priceRepo,
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly BoardServiceRepository $boardServiceRepo,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly EntityManagerInterface $entityManager,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly CmsDataProvider $cmsDataProvider,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly string $officeEmail,
) {
}
/**
* Parses query params, resolves the accommodation by calendar code, and returns a fresh DTO.
*
* @throws \InvalidArgumentException if the hotel code is not found or dates are invalid
*/
public function initFromParams(AccommodationBookingQueryParams $params): AccommodationBookingDto
{
$accommodation = $this->accommodationRepo->findOneBy(['calendarCode' => $params->hotelCode]);
if (null === $accommodation) {
throw new \InvalidArgumentException(sprintf('Unterkunft mit Code "%s" nicht gefunden.', $params->hotelCode));
}
$dto = new AccommodationBookingDto();
$dto->accommodationId = $accommodation->getId();
if (null !== $params->dateFrom && null !== $params->dateTo) {
$this->applyDates($dto, $params->dateFrom, $params->dateTo);
$dateFrom = $dto->dateFrom;
$dateTo = $dto->dateTo;
if (null !== $dateFrom && null !== $dateTo) {
$dto->paxCount = $this->resolveMinPax($params->hotelCode, $dateFrom, $dateTo);
}
}
return $dto;
}
/**
* Parses and validates raw date strings, then sets them on the DTO.
*
* @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom
*/
public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
{
$dateFrom = $this->parseDate($dateFromRaw);
$dateTo = $this->parseDate($dateToRaw);
if (null === $dateFrom || null === $dateTo) {
throw new \InvalidArgumentException('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.');
}
if ($dateTo <= $dateFrom) {
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.');
}
$dto->dateFrom = $dateFrom->setTime(0, 0, 0);
$dto->dateTo = $dateTo->setTime(0, 0, 0);
}
public function computeInitialPaxCount(Accommodation $accommodation, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): int
{
return $this->resolveMinPax($accommodation->getCalendarCode() ?? '', $dateFrom, $dateTo);
}
public function loadAccommodation(int $id): ?Accommodation
{
return $this->accommodationRepo->find($id);
}
/**
* Fetches hotel name, image, and address from the CMS for display in the sidebar.
*
* @return array<string, mixed>|null null on CMS error or missing code
*/
public function loadHotelCmsData(Accommodation $accommodation): ?array
{
$code = $accommodation->getEffectiveCmsCode();
$data = $this->cmsDataProvider->getHotelDetails($code);
return isset($data['success']) && false === $data['success'] ? null : $data;
}
/**
* @return AccommodationPrice[]
*/
public function loadPrices(AccommodationBookingDto $dto, Accommodation $accommodation): array
{
return $this->priceRepo->findByHotelCodeAndDateRange(
$accommodation->getCalendarCode() ?? '',
$dto->dateFrom,
$dto->dateTo,
);
}
/**
* @return array{
* boardServices: BoardService[],
* additionalServices: AdditionalService[],
* groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[]
* }
*/
public function loadAvailableServices(AccommodationBookingDto $dto, Accommodation $accommodation): array
{
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$dto->dateFrom,
$dto->dateTo,
);
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$dto->dateFrom,
$dto->dateTo,
);
$grouped = [];
$ungrouped = [];
foreach ($additionalServices as $service) {
if (null !== $service->getSelectionGroup()) {
$grouped[$service->getSelectionGroup()][] = $service;
} else {
$ungrouped[] = $service;
}
}
return [
'boardServices' => $boardServices,
'additionalServices' => $additionalServices,
'groupedAdditionalServices' => $grouped,
'ungroupedAdditionalServices' => $ungrouped,
];
}
/**
* Determines whether the booking must be treated as non-binding and why.
*
* The effective price for the first day of the booking window is used as the authoritative
* source for minNights and includedPax rules. This mirrors how the pricing side works —
* per-day prices are iterated for cost calculation, but the booking-mode decision is
* anchored to the first night's effective price.
*
* PriceTimelineBuilder::resolveWinner() resolves the DISCOUNT > OVERRIDE > base priority.
*
* @param AccommodationPrice[] $prices
*/
public function computeInquiryStatus(AccommodationBookingDto $dto, array $prices): InquiryStatus
{
if (empty($prices)) {
return new InquiryStatus(true, ['Für den gewählten Zeitraum ist kein Preis hinterlegt.']);
}
$nights = $dto->getNights();
$firstDay = $dto->dateFrom;
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
if (null === $winner) {
return new InquiryStatus(true, ['Für den Anreisezeitpunkt ist kein Preis hinterlegt.']);
}
$reasons = [];
$adultPax = $dto->paxCount - $dto->minorsCount;
if ($adultPax < $winner->getIncludedPax() && !$winner->isAcceptUndersubscription()) {
$reasons[] = sprintf(
'Mindestpersonenzahl: %d (gebucht: %d)',
$winner->getIncludedPax(),
$adultPax,
);
}
if ($nights < $winner->getMinNights() && !$winner->isAcceptShortTerm()) {
$reasons[] = sprintf(
'Mindestaufenthalt: %d Nächte (gebucht: %d)',
$winner->getMinNights(),
$nights,
);
}
return new InquiryStatus(!empty($reasons), $reasons);
}
/**
* Persists the completed booking to the database.
*
* Board and additional services are stored as frozen snapshots — independent of
* the catalog entities so that future catalog edits do not affect existing bookings.
*
* @param AccommodationPrice[] $prices
* @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services
*/
public function persist(
AccommodationBookingDto $dto,
Accommodation $accommodation,
array $prices,
array $services,
): AccommodationBooking {
$booking = new AccommodationBooking();
$booking->setAccommodation($accommodation);
$booking->setGroupName($dto->groupName);
$booking->setDateFrom($dto->dateFrom);
$booking->setDateTo($dto->dateTo);
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
$booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry);
// Freeze board service as scalar fields (no FK)
if (null !== $dto->selectedBoardServiceId) {
foreach ($services['boardServices'] as $boardService) {
if ($boardService->getId() === $dto->selectedBoardServiceId) {
$booking->setBoardServiceLabel($boardService->getLabel());
$booking->setBoardServicePrice($boardService->getPrice());
$booking->setBoardServiceOriginalId($boardService->getId());
break;
}
}
}
// Freeze additional services as JSON snapshots (no FK to catalog)
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
foreach ($services['additionalServices'] as $additionalService) {
if (!isset($selectedIds[$additionalService->getId()])) {
continue;
}
$booking->addAdditionalServiceSnapshot(
$additionalService->getLabel(),
$additionalService->getPrice(),
$additionalService->getType(),
$additionalService->getId(),
);
}
// Personal data
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
$booking->setRemarks($dto->remarks);
$this->entityManager->persist($booking);
$this->entityManager->flush();
return $booking;
}
/** @param array<string, mixed> $priceBreakdown */
public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'priceBreakdown' => $priceBreakdown,
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $this->officeEmail,
'subject' => sprintf(
'Neue Unterkunfts%s: %s',
$booking->isInquiry() ? 'anfrage' : 'buchung',
$booking->getAccommodation()?->getName(),
),
'template' => 'email/accommodation_booking.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send accommodation booking notification email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
/**
* Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet.
* No email side effect — the confirmation email is always sent separately via
* sendCustomerConfirmationEmail(), regardless of whether a link exists.
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) {
return;
}
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
/**
* Always sends a confirmation email to the customer, whether or not an access link
* exists yet — the template renders differently depending on accessLink being present.
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [],
'accessLink' => $this->accessLinkOrNull($booking),
],
[
'to' => $booking->getEmail(),
'subject' => $booking->isInquiry()
? 'Deine Anfrage ist bei uns eingegangen'
: 'Deine Buchung ist bestätigt',
'template' => 'email/accommodation_booking_customer.html.twig',
'attachments' => [],
],
);
} catch (\Throwable $e) {
$this->logger->error('Failed to send accommodation booking customer confirmation email', [
'booking_id' => $booking->getId(),
'error' => $e->getMessage(),
]);
}
}
/**
* 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.
*/
public function regenerateAccessLinkAndNotifyCustomer(AccommodationBooking $booking): void
{
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$this->entityManager->flush();
$this->sendCustomerConfirmationEmail($booking);
}
/**
* Transitions a booking from inquiry to booking. Idempotent — a no-op if the booking
* is already accepted.
*/
public function acceptBooking(AccommodationBooking $booking): void
{
if (!$booking->isInquiry()) {
return;
}
$booking->setIsInquiry(false);
$booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
private function accessLinkOrNull(AccommodationBooking $booking): ?string
{
return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null;
}
private function parseDate(string $value): ?\DateTimeImmutable
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = \DateTimeImmutable::getLastErrors();
if (false === $date) {
return null;
}
if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
return null;
}
if ($date->format('Y-m-d') !== $value) {
return null;
}
return $date;
}
private function resolveMinPax(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): int {
$prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom,
));
return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\HttpFoundation\Request;
class AccommodationSessionManager
{
// Key kept as accommodation_inquiry for session continuity during the inquiry→booking rename.
public const SESSION_KEY = 'accommodation_inquiry';
public function getDto(Request $request): ?AccommodationBookingDto
{
$session = $request->getSession();
if (!$session->has(self::SESSION_KEY)) {
return null;
}
$dto = $session->get(self::SESSION_KEY);
if (!$dto instanceof AccommodationBookingDto) {
return null;
}
return $dto;
}
public function getOrFail(Request $request): AccommodationBookingDto
{
$dto = $this->getDto($request);
if (null === $dto) {
throw new AccommodationSessionNotFoundException();
}
return $dto;
}
public function save(Request $request, AccommodationBookingDto $dto): void
{
$request->getSession()->set(self::SESSION_KEY, $dto);
}
public function clear(Request $request): void
{
$request->getSession()->remove(self::SESSION_KEY);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Service;
class CalendarGridBuilder
{
private const array MONTH_NAMES = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
/**
* Builds a calendar structure for $count months starting from the 1st of $from's month.
*
* @return list<array{month: \DateTimeImmutable, label: string, weeks: list<list<array{date: \DateTimeImmutable, inMonth: bool}>>}>
*/
public function buildMonths(\DateTimeImmutable $from, int $count = 18): array
{
$months = [];
$base = $from->modify('first day of this month')->setTime(0, 0, 0);
for ($i = 0; $i < $count; $i++) {
$monthStart = $base->modify("+{$i} months");
$months[] = [
'month' => $monthStart,
'label' => self::MONTH_NAMES[(int) $monthStart->format('n') - 1].' '.$monthStart->format('Y'),
'weeks' => $this->buildWeeks($monthStart),
];
}
return $months;
}
/**
* @return list<list<array{date: \DateTimeImmutable, inMonth: bool}>>
*/
private function buildWeeks(\DateTimeImmutable $monthStart): array
{
$monthEnd = $monthStart->modify('last day of this month');
$monthKey = $monthStart->format('Y-m');
// Monday of the week containing the 1st (ISO week: Mon=1)
$isoDay = (int) $monthStart->format('N');
$gridStart = $monthStart->modify('-'.($isoDay - 1).' days');
// Sunday of the week containing the last day
$isoDay = (int) $monthEnd->format('N');
$gridEnd = $monthEnd->modify('+'.(7 - $isoDay).' days');
$weeks = [];
$current = $gridStart;
while ($current <= $gridEnd) {
$week = [];
for ($d = 0; $d < 7; $d++) {
$week[] = [
'date' => $current,
'inMonth' => $current->format('Y-m') === $monthKey,
];
$current = $current->modify('+1 day');
}
$weeks[] = $week;
}
return $weeks;
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AdditionalServiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class GroupsPriceCalculator
{
/** @var array<int, int> short-term surcharge percentages (nights → percent) */
private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10];
/** @var array<string, int|float> */
private array $config;
public function __construct(
private readonly PriceTimelineBuilder $priceTimelineBuilder,
array $config,
) {
$this->config = $this->resolveConfig($config);
}
/**
* @param array<string, int|float> $config
* @return array<string, int|float>
*/
private function resolveConfig(array $config): array
{
$resolver = new OptionsResolver();
$resolver->setRequired(['runningCostsEur', 'runningCostsChf', 'undersubscription30Eur', 'undersubscription30Chf', 'undersubscription40Eur', 'undersubscription40Chf']);
$resolver->setAllowedTypes('runningCostsEur', ['int', 'float']);
$resolver->setAllowedTypes('runningCostsChf', ['int', 'float']);
$resolver->setAllowedTypes('undersubscription30Eur', ['int', 'float']); // per-person/night surcharge when effectivePax < 40
$resolver->setAllowedTypes('undersubscription30Chf', ['int', 'float']);
$resolver->setAllowedTypes('undersubscription40Eur', ['int', 'float']); // per-person/night surcharge when 40 ≤ effectivePax < 50
$resolver->setAllowedTypes('undersubscription40Chf', ['int', 'float']);
return $resolver->resolve($config);
}
/**
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* @param AdditionalService[] $additionalServices only the selected services
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
public function calculate(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?BoardService $boardService,
array $additionalServices,
string $currency,
): array {
return $this->doCalculate(
$paxCount,
$minorsCount,
$nights,
$dateFrom,
$dateTo,
$prices,
$boardService?->getPrice(),
array_map(
fn(AdditionalService $s) => [
'label' => $s->getLabel() ?? '',
'price' => $s->getPrice() ?? 0,
'type' => $s->getType(),
],
$additionalServices,
),
$currency,
);
}
/**
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
* instead of live catalog entities.
*
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
public function calculateFromSnapshots(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalServiceSnapshots,
string $currency,
): array {
$items = array_map(
fn(array $s) => [
'label' => $s['label'] ?? '',
'price' => $s['price'] ?? 0,
'type' => AdditionalServiceType::tryFrom($s['type'] ?? '') ?? AdditionalServiceType::Flat,
],
$additionalServiceSnapshots,
);
return $this->doCalculate(
$paxCount,
$minorsCount,
$nights,
$dateFrom,
$dateTo,
$prices,
$boardServicePricePerPersonNight,
$items,
$currency,
);
}
/**
* @param AccommodationPrice[] $prices
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $additionalItems
*
* @return array{
* effectivePax: int,
* totalPax: int,
* includedPax: int,
* nights: int,
* basePrice: int,
* additionalPersonsPrice: int,
* shortTermSurcharge: int,
* undersubscriptionSurcharge: int,
* undersubscriptionThreshold: int|null,
* boardPrice: int,
* serviceDetails: list<array{label: string, price: int}>,
* servicesPrice: int,
* runningCosts: int,
* total: int,
* currency: string,
* }
*/
private function doCalculate(
int $paxCount,
int $minorsCount,
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalItems,
string $currency,
): array {
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
$firstCandidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
));
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
$effectivePax = max($paxCount - $minorsCount, $includedPaxFloor);
// Rules 1 & 2: per-night base price + additional persons.
$boundaryMap = [$dateFrom->format('Y-m-d') => $dateFrom, $dateTo->format('Y-m-d') => $dateTo];
foreach ($prices as $p) {
$from = $p->getDateFrom();
$toNext = $p->getDateTo()->modify('+1 day');
if ($from > $dateFrom && $from < $dateTo) {
$boundaryMap[$from->format('Y-m-d')] = $from;
}
if ($toNext > $dateFrom && $toNext < $dateTo) {
$boundaryMap[$toNext->format('Y-m-d')] = $toNext;
}
}
ksort($boundaryMap);
$sortedBoundaries = array_values($boundaryMap);
$basePrice = 0;
$additionalPersonsPrice = 0;
$lastWinner = null;
for ($i = 0, $end = count($sortedBoundaries) - 1; $i < $end; ++$i) {
$segStart = $sortedBoundaries[$i];
$segNights = $segStart->diff($sortedBoundaries[$i + 1])->days;
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
if ($winner !== null) {
$lastWinner = $winner;
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
$includedPax = $winner->getIncludedPax() ?? 0;
if ($effectivePax > $includedPax) {
$additionalPersonsPrice += ($effectivePax - $includedPax) * ($winner->getPriceAdditionalPerson() ?? 0) * $segNights;
}
}
}
// Rule 3: short-term surcharge
$shortTermSurcharge = 0;
if (isset(self::SHORT_TERM_FACTORS[$nights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100);
}
// Rule 4: undersubscription surcharge (only when board is selected)
$undersubscriptionSurcharge = 0;
$undersubscriptionThreshold = null;
if ($boardServicePricePerPersonNight !== null) {
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
if ($effectivePax < 40) {
$undersubscriptionSurcharge = $effectivePax * $surcharge30 * $nights;
$undersubscriptionThreshold = 40;
} elseif ($effectivePax < 50) {
$undersubscriptionSurcharge = $effectivePax * $surcharge40 * $nights;
$undersubscriptionThreshold = 50;
}
}
// Rule 5: board price
$boardPrice = 0;
if ($boardServicePricePerPersonNight !== null) {
$boardPrice = $boardServicePricePerPersonNight * $effectivePax * $nights;
}
// Rule 6: additional services
$serviceDetails = [];
$servicesPrice = 0;
foreach ($additionalItems as $item) {
$price = match ($item['type']) {
AdditionalServiceType::Flat => $item['price'],
AdditionalServiceType::PerPerson => $item['price'] * $effectivePax,
AdditionalServiceType::PerNight => $item['price'] * $nights,
AdditionalServiceType::PerPersonPerNight => $item['price'] * $effectivePax * $nights,
};
$servicesPrice += $price;
$serviceDetails[] = ['label' => $item['label'], 'price' => $price];
}
// Rule 7: running costs — uses full paxCount (all persons including 0–3 year olds)
$runningCostFactor = (int) round(('CHF' === $currency ? $this->config['runningCostsChf'] : $this->config['runningCostsEur']) * 100);
$runningCosts = $paxCount * $nights * $runningCostFactor;
$total = $basePrice + $additionalPersonsPrice + $shortTermSurcharge
+ $undersubscriptionSurcharge + $boardPrice + $servicesPrice + $runningCosts;
return [
'effectivePax' => $effectivePax,
'totalPax' => $paxCount,
'includedPax' => $includedPaxFloor,
'nights' => $nights,
'basePrice' => $basePrice,
'additionalPersonsPrice' => $additionalPersonsPrice,
'shortTermSurcharge' => $shortTermSurcharge,
'undersubscriptionSurcharge' => $undersubscriptionSurcharge,
'undersubscriptionThreshold' => $undersubscriptionThreshold,
'boardPrice' => $boardPrice,
'serviceDetails' => $serviceDetails,
'servicesPrice' => $servicesPrice,
'runningCosts' => $runningCosts,
'total' => $total,
'currency' => $currency,
];
}
}