feat: groups price calculator admin crud, booking/offer flow and api
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
abstract class AbstractApiClient
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly HttpClientInterface $httpClient,
|
||||
protected readonly LoggerInterface $logger,
|
||||
protected readonly ?string $baseUrl = null,
|
||||
protected readonly ?string $apiKey = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function request(string $path, array $query = []): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $this->baseUrl.$path, [
|
||||
'headers' => [
|
||||
'X-API-KEY' => $this->apiKey,
|
||||
],
|
||||
'query' => $query,
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->logger->error('BpnConnect request failed', [
|
||||
'path' => $path,
|
||||
'query' => $query,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw new BpnConnectException('BpnConnect request failed: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (null === $this->baseUrl || null === $this->apiKey) {
|
||||
throw new BpnConnectException('BpnConnect client is not configured (missing BPN_CONNECT_BASE_URL or BPN_CONNECT_API_KEY).');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Model\ContingentCalendarEntry;
|
||||
use App\BpnConnect\Model\ContingentCalendarMeta;
|
||||
use App\BpnConnect\Model\ContingentCalendarResponse;
|
||||
use App\BpnConnect\Model\ContingentMode;
|
||||
use App\BpnConnect\Model\ContingentRangeEntry;
|
||||
use App\BpnConnect\Model\ContingentRangeResponse;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
|
||||
class ContingentsClient extends AbstractApiClient
|
||||
{
|
||||
private const string CALENDAR_PATH = '/api/v1/contingents/calendar';
|
||||
|
||||
public function getContingentCalendar(
|
||||
string $hotelCode,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
): ContingentCalendarResponse {
|
||||
$payload = $this->request(self::CALENDAR_PATH, [
|
||||
'hotelCode' => $hotelCode,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'mode' => ContingentMode::Days->value,
|
||||
]);
|
||||
|
||||
$entries = array_map(
|
||||
static fn (array $item) => new ContingentCalendarEntry(
|
||||
date: $item['date'],
|
||||
status: ContingentStatus::from($item['status']),
|
||||
),
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new ContingentCalendarResponse($this->buildMeta($payload), $entries);
|
||||
}
|
||||
|
||||
public function getContingentRanges(
|
||||
string $hotelCode,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
): ContingentRangeResponse {
|
||||
$payload = $this->request(self::CALENDAR_PATH, [
|
||||
'hotelCode' => $hotelCode,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'mode' => ContingentMode::Ranges->value,
|
||||
]);
|
||||
|
||||
$entries = array_map(
|
||||
static fn (array $item) => new ContingentRangeEntry(
|
||||
dateFrom: $item['date_from'],
|
||||
dateTo: $item['date_to'],
|
||||
status: ContingentStatus::from($item['status']),
|
||||
),
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new ContingentRangeResponse($this->buildMeta($payload), $entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function buildMeta(array $payload): ContingentCalendarMeta
|
||||
{
|
||||
$raw = $payload['meta'] ?? [];
|
||||
|
||||
return new ContingentCalendarMeta(
|
||||
dateFrom: $raw['date_from'] ?? '',
|
||||
dateTo: $raw['date_to'] ?? '',
|
||||
mode: $raw['mode'] ?? '',
|
||||
hotelCode: $raw['hotel_code'] ?? '',
|
||||
hotelId: (int) ($raw['hotel_id'] ?? 0),
|
||||
rowCount: (int) ($raw['row_count'] ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Exception;
|
||||
|
||||
class BpnConnectException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class ContingentCalendarEntry
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public ContingentStatus $status,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class ContingentCalendarMeta
|
||||
{
|
||||
public function __construct(
|
||||
public string $dateFrom,
|
||||
public string $dateTo,
|
||||
public string $mode,
|
||||
public string $hotelCode,
|
||||
public int $hotelId,
|
||||
public int $rowCount,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class ContingentCalendarResponse
|
||||
{
|
||||
/**
|
||||
* @param ContingentCalendarEntry[] $data
|
||||
*/
|
||||
public function __construct(
|
||||
public ContingentCalendarMeta $meta,
|
||||
public array $data,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
enum ContingentMode: string
|
||||
{
|
||||
case Days = 'days';
|
||||
case Ranges = 'ranges';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class ContingentRangeEntry
|
||||
{
|
||||
public function __construct(
|
||||
public string $dateFrom,
|
||||
public string $dateTo,
|
||||
public ContingentStatus $status,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class ContingentRangeResponse
|
||||
{
|
||||
/**
|
||||
* @param ContingentRangeEntry[] $data
|
||||
*/
|
||||
public function __construct(
|
||||
public ContingentCalendarMeta $meta,
|
||||
public array $data,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
enum ContingentStatus: string
|
||||
{
|
||||
case Ok = 'OK';
|
||||
case Blocked = 'BLOCKED';
|
||||
case OnRequest = 'ON_REQUEST';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'enum.contingent_status.'.strtolower($this->value);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class CrmAttributesResponseParser
|
||||
private const BPN_CRM_ID_ADMIN = 1292;
|
||||
private const BPN_CRM_ID_MANAGER = 1293;
|
||||
private const BPN_CRM_ID_TEAMER = 1070;
|
||||
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
|
||||
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
|
||||
private const BPN_DEFAULT_HOTEL_CODE = 'SSL';
|
||||
|
||||
public function parse(Crawler $result): CrmAttributes
|
||||
@@ -57,6 +59,12 @@ class CrmAttributesResponseParser
|
||||
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
|
||||
$roles[] = 'ROLE_TEAMER';
|
||||
}
|
||||
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) {
|
||||
$roles[] = 'ROLE_GROUPS_MANAGER';
|
||||
}
|
||||
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) {
|
||||
$roles[] = 'ROLE_GROUPS_ADMIN';
|
||||
}
|
||||
|
||||
$attributes[] = $attribute;
|
||||
})
|
||||
|
||||
@@ -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, AdditionalServiceType> 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,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Accommodation;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class CalendarController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/accommodation/{id}/calendar', name: 'app_admin_accommodation_calendar', methods: ['GET'])]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'startMonth' => $request->query->get('month'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Accommodation;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Form\Admin\Groups\AccommodationType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation/create', name: 'app_admin_accommodation_create')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$accommodation = new Accommodation();
|
||||
|
||||
$form = $this->createForm(AccommodationType::class, $accommodation, ['hx_post' => $request->getRequestUri()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($accommodation);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Das Gruppenhaus wurde angelegt');
|
||||
|
||||
$this->logger->info('Created accommodation', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodation'));
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation/modal_create.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Accommodation;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation/{id}/delete', name: 'app_admin_accommodation_delete')]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('delete_accommodation_'.$accommodation->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($accommodation);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Das Gruppenhaus wurde gelöscht');
|
||||
|
||||
$this->logger->info('Deleted accommodation', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodation'));
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation/modal_delete.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Accommodation;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Form\Admin\Groups\AccommodationType;
|
||||
use App\Service\CmsDataProvider;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation/{id}/edit', name: 'app_admin_accommodation_edit')]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
// fetch additional data from cms if available
|
||||
$cmsData = null;
|
||||
|
||||
if (null !== $accommodation->getCalendarCode()) {
|
||||
$cmsData = $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
|
||||
}
|
||||
|
||||
$form = $this->createForm(AccommodationType::class, $accommodation);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($accommodation);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Das Gruppenhaus wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Updated accommodation', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', ['id' => $accommodation->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation/edit.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'form' => $form,
|
||||
'cmsData' => $cmsData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Accommodation;
|
||||
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation', name: 'app_admin_accommodation')]
|
||||
public function index(): Response
|
||||
{
|
||||
$accommodations = $this->accommodationRepository->findBy([], ['calendarCode' => 'ASC']);
|
||||
|
||||
return $this->render('admin/accommodation/index.html.twig', [
|
||||
'accommodations' => $accommodations,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
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->bookingService->refreshPriceSnapshot($booking);
|
||||
$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,133 @@
|
||||
<?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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
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->bookingService->refreshPriceSnapshot($booking);
|
||||
|
||||
$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,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
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)) {
|
||||
if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->bookingService->regenerateAccessLink($booking);
|
||||
|
||||
$this->addFlash('success', 'Der Zugangslink wurde neu generiert.');
|
||||
|
||||
$this->logger->info('Generated accommodation booking access link', [
|
||||
'id' => $booking->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [
|
||||
'booking' => $booking,
|
||||
'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
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.createdAt',
|
||||
'defaultSortDirection' => 'DESC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/accommodation_booking/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
class SendAccessLinkController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}/send-access-link', name: 'app_admin_accommodationbooking_send_access_link')]
|
||||
public function index(AccommodationBooking $booking, Request $request): Response
|
||||
{
|
||||
if (null === $booking->getAccessLinkIssuedAt()) {
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('send_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
||||
|
||||
$this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.');
|
||||
|
||||
$this->logger->info('Sent accommodation booking access link', [
|
||||
'id' => $booking->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_booking/modal_send_access_link.html.twig', [
|
||||
'booking' => $booking,
|
||||
'csrf_token_id' => 'send_accommodation_booking_access_link_'.$booking->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationPrice;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Form\Admin\Groups\AccommodationPriceType;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-price/{id}/create', name: 'app_admin_accommodationprice_create')]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
$accommodationPrice = new AccommodationPrice();
|
||||
|
||||
$form = $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$accommodation->addAccommodationPrice($accommodationPrice);
|
||||
|
||||
$this->entityManager->persist($accommodationPrice);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Preis wurde angelegt');
|
||||
|
||||
$this->logger->info('Created accommodation price', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'date_range' => [
|
||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||
]
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/create.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationPrice;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')]
|
||||
public function index(AccommodationPrice $accommodationPrice, Request $request): Response
|
||||
{
|
||||
$accommodation = $accommodationPrice->getAccommodation();
|
||||
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('delete_accommodation_price_'.$accommodationPrice->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($accommodationPrice);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Preis wurde gelöscht');
|
||||
|
||||
$this->logger->info('Deleted accommodation price', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'date_range' => [
|
||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||
],
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/modal_delete.html.twig', [
|
||||
'accommodationPrice' => $accommodationPrice,
|
||||
'accommodation' => $accommodation,
|
||||
'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationPrice;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Form\Admin\Groups\AccommodationPriceType;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-price/{id}/edit', name: 'app_admin_accommodationprice_edit', defaults: ['duplicate' => false])]
|
||||
#[Route('/admin/accommodation-price/{id}/duplicate', name: 'app_admin_accommodationprice_duplicate', defaults: ['duplicate' => true])]
|
||||
public function index(AccommodationPrice $accommodationPrice, bool $duplicate, Request $request): Response
|
||||
{
|
||||
$accommodation = $accommodationPrice->getAccommodation();
|
||||
|
||||
if (true === $duplicate) {
|
||||
$accommodationPrice = clone $accommodationPrice;
|
||||
}
|
||||
|
||||
$form = $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (true === $duplicate) {
|
||||
$accommodation->addAccommodationPrice($accommodationPrice);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($accommodationPrice);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Preis wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Edited accommodation price', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'date_range' => [
|
||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||
]
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/edit.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'accommodationPrice' => $accommodationPrice,
|
||||
'form' => $form,
|
||||
'duplicate' => $duplicate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/additional-service/{id}/create', name: 'app_admin_additionalservice_create')]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
$additionalService = new AdditionalService();
|
||||
|
||||
$form = $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$accommodation->addAdditionalService($additionalService);
|
||||
|
||||
$this->entityManager->persist($additionalService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zusatzleistung wurde angelegt');
|
||||
|
||||
$this->logger->info('Created additional service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $additionalService->getLabel(),
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'accommodation' => $accommodation,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/additional-service/{id}/delete', name: 'app_admin_additionalservice_delete')]
|
||||
public function index(AdditionalService $additionalService, Request $request): Response
|
||||
{
|
||||
$accommodation = $additionalService->getAccommodation();
|
||||
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('delete_additional_service_'.$additionalService->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($additionalService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zusatzleistung wurde gelöscht');
|
||||
|
||||
$this->logger->info('Deleted additional service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $additionalService->getLabel(),
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl.'#additional-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/additional_service/modal_delete.html.twig', [
|
||||
'additionalService' => $additionalService,
|
||||
'accommodation' => $accommodation,
|
||||
'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/additional-service/{id}/edit', name: 'app_admin_additionalservice_edit', defaults: ['duplicate' => false])]
|
||||
#[Route('/admin/additional-service/{id}/duplicate', name: 'app_admin_additionalservice_duplicate', defaults: ['duplicate' => true])]
|
||||
public function index(AdditionalService $additionalService, bool $duplicate, Request $request): Response
|
||||
{
|
||||
$accommodation = $additionalService->getAccommodation();
|
||||
|
||||
if (true === $duplicate) {
|
||||
$additionalService = clone $additionalService;
|
||||
}
|
||||
|
||||
$form = $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (true === $duplicate) {
|
||||
$accommodation->addAdditionalService($additionalService);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($additionalService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zusatzleistung wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Edited additional service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $additionalService->getLabel(),
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'accommodation' => $accommodation,
|
||||
'additionalService' => $additionalService,
|
||||
'form' => $form,
|
||||
'duplicate' => $duplicate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Admin\Groups\BoardServiceType;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/board-service/{id}/create', name: 'app_admin_boardservice_create')]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
$boardService = new BoardService();
|
||||
|
||||
$form = $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$accommodation->addBoardService($boardService);
|
||||
|
||||
$this->entityManager->persist($boardService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Verpflegungsleistung wurde angelegt');
|
||||
|
||||
$this->logger->info('Created board service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $boardService->getLabel(),
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'accommodation' => $accommodation,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/board-service/{id}/delete', name: 'app_admin_boardservice_delete')]
|
||||
public function index(BoardService $boardService, Request $request): Response
|
||||
{
|
||||
$accommodation = $boardService->getAccommodation();
|
||||
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('delete_board_service_'.$boardService->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($boardService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Verpflegungsleistung wurde gelöscht');
|
||||
|
||||
$this->logger->info('Deleted board service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $boardService->getLabel(),
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl.'#board-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/board_service/modal_delete.html.twig', [
|
||||
'boardService' => $boardService,
|
||||
'accommodation' => $accommodation,
|
||||
'csrf_token_id' => 'delete_board_service_'.$boardService->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Admin\Groups\BoardServiceType;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/board-service/{id}/edit', name: 'app_admin_boardservice_edit', defaults: ['duplicate' => false])]
|
||||
#[Route('/admin/board-service/{id}/duplicate', name: 'app_admin_boardservice_duplicate', defaults: ['duplicate' => true])]
|
||||
public function index(BoardService $boardService, bool $duplicate, Request $request): Response
|
||||
{
|
||||
$accommodation = $boardService->getAccommodation();
|
||||
|
||||
if (true === $duplicate) {
|
||||
$boardService = clone $boardService;
|
||||
}
|
||||
|
||||
$form = $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (true === $duplicate) {
|
||||
$accommodation->addBoardService($boardService);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($boardService);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Verpflegungsleistung wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Edited board service', [
|
||||
'name' => $accommodation->getName(),
|
||||
'code' => $accommodation->getCalendarCode(),
|
||||
'label' => $boardService->getLabel(),
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'accommodation' => $accommodation,
|
||||
'boardService' => $boardService,
|
||||
'form' => $form,
|
||||
'duplicate' => $duplicate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BookingEditDraft;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $adminLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/delete', name: 'app_admin_bookingeditdraft_delete')]
|
||||
public function index(BookingEditDraft $draft, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('delete_booking_edit_draft_'.$draft->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($draft);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->adminLogger->info('Delete booking edit draft', [
|
||||
'booking_number' => $draft->getBookingNumber(),
|
||||
]);
|
||||
$this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht');
|
||||
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
}
|
||||
|
||||
return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [
|
||||
'draft' => $draft,
|
||||
'csrf_token_id' => 'delete_booking_edit_draft_'.$draft->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BookingEditDraft;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Service\BookingExporter;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class ExportController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(private readonly BookingExporter $bookingExporter)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/export', name: 'app_admin_bookingeditdraft_export')]
|
||||
public function index(BookingEditDraft $draft): Response
|
||||
{
|
||||
if (false === $draft->hasExportData()) {
|
||||
$this->addFlash('warning', 'Der Export ist fehlgeschlagen');
|
||||
|
||||
return $this->redirectToRoute('app_admin_bookingeditdraft');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->bookingExporter->createExportResponse($draft);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_admin_bookingeditdraft');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BookingEditDraft;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$qb = $this
|
||||
->bookingEditDraftRepository
|
||||
->createQueryBuilder('booking_edit_draft')
|
||||
->leftJoin('booking_edit_draft.user', 'user')
|
||||
;
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$qb,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 20),
|
||||
[
|
||||
'defaultSortFieldName' => 'booking_edit_draft.createdAt',
|
||||
'defaultSortDirection' => 'DESC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/booking_edit_draft/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BookingEditDraft;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class ShowController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/show', name: 'app_admin_bookingeditdraft_show')]
|
||||
public function index(BookingEditDraft $draft, Request $request): Response
|
||||
{
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
|
||||
|
||||
return $this->render('admin/booking_edit_draft/show.html.twig', [
|
||||
'draft' => $draft,
|
||||
'returnUrl' => $returnUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use App\Service\BookingExporter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
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 BookingEditDraftController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
private readonly BookingExporter $bookingExporter,
|
||||
private readonly LoggerInterface $adminLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$qb = $this
|
||||
->bookingEditDraftRepository
|
||||
->createQueryBuilder('booking_edit_draft')
|
||||
->leftJoin('booking_edit_draft.user', 'user')
|
||||
;
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$qb,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 20),
|
||||
[
|
||||
'defaultSortFieldName' => 'booking_edit_draft.createdAt',
|
||||
'defaultSortDirection' => 'DESC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/booking_edit_draft/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/show', name: 'app_admin_bookingeditdraft_show')]
|
||||
public function show(BookingEditDraft $draft, Request $request): Response
|
||||
{
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
|
||||
|
||||
return $this->render('admin/booking_edit_draft/show.html.twig', [
|
||||
'draft' => $draft,
|
||||
'returnUrl' => $returnUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/delete', name: 'app_admin_bookingeditdraft_delete')]
|
||||
public function delete(BookingEditDraft $draft, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->entityManager->remove($draft);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->adminLogger->info('Delete booking edit draft', [
|
||||
'booking_number' => $draft->getBookingNumber(),
|
||||
]);
|
||||
$this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht');
|
||||
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
}
|
||||
|
||||
return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [
|
||||
'draft' => $draft,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/{id}/export', name: 'app_admin_bookingeditdraft_export')]
|
||||
public function export(BookingEditDraft $draft): Response
|
||||
{
|
||||
if (false === $draft->hasExportData()) {
|
||||
$this->addFlash('warning', 'Der Export ist fehlgeschlagen');
|
||||
|
||||
return $this->redirectToRoute('app_admin_bookingeditdraft');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->bookingExporter->createExportResponse($draft);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_admin_bookingeditdraft');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\Voter\AdministrativeAccessVoter;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Log;
|
||||
|
||||
use App\Service\XmlDumpReader;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class DownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/log/download/{filename}', name: 'app_admin_log_xmldump_download', requirements: ['filename' => '.+'])]
|
||||
public function index(string $filename): Response
|
||||
{
|
||||
try {
|
||||
if (false === $this->xmlDumpReader->fileExists($filename)) {
|
||||
throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.');
|
||||
}
|
||||
|
||||
$content = $this->xmlDumpReader->getContent($filename);
|
||||
} catch (FilesystemException $e) {
|
||||
throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', 'application/xml');
|
||||
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
basename($filename)
|
||||
);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Log;
|
||||
|
||||
use App\Repository\LogEntryRepository;
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LogEntryRepository $logEntryRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/log', name: 'app_admin_log')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$qb = $this
|
||||
->logEntryRepository
|
||||
->createQueryBuilder('log_entry')
|
||||
;
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$qb,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
'defaultSortFieldName' => 'log_entry.createdAt',
|
||||
'defaultSortDirection' => 'DESC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/log/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Log;
|
||||
|
||||
use App\Entity\LogEntry;
|
||||
use App\Service\XmlDumpReader;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class XmlDumpController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/log/{id}/xml-dumps', name: 'app_admin_log_xmldumps')]
|
||||
public function index(LogEntry $logEntry): Response
|
||||
{
|
||||
$dumps = [];
|
||||
try {
|
||||
$dumps = $this->xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId());
|
||||
} catch (FilesystemException) {
|
||||
}
|
||||
|
||||
return $this->render('admin/log/xml_dumps.html.twig', [
|
||||
'logEntry' => $logEntry,
|
||||
'dumps' => $dumps,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Log;
|
||||
use App\Entity\LogEntry;
|
||||
use App\Repository\LogEntryRepository;
|
||||
use App\Service\XmlDumpReader;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class LogController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LogEntryRepository $logEntryRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
private readonly XmlDumpReader $xmlDumpReader,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/log', name: 'app_admin_log')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$qb = $this
|
||||
->logEntryRepository
|
||||
->createQueryBuilder('log_entry')
|
||||
;
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$qb,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
'defaultSortFieldName' => 'log_entry.createdAt',
|
||||
'defaultSortDirection' => 'DESC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/log/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/log/{id}/xml-dumps', name: 'app_admin_log_xmldumps')]
|
||||
public function xmlDumps(LogEntry $logEntry): Response
|
||||
{
|
||||
$dumps = [];
|
||||
try {
|
||||
$dumps = $this->xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId());
|
||||
} catch (FilesystemException) {
|
||||
}
|
||||
|
||||
return $this->render('admin/log/xml_dumps.html.twig', [
|
||||
'logEntry' => $logEntry,
|
||||
'dumps' => $dumps,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/log/download/{filename}', name: 'app_admin_log_xmldump_download', requirements: ['filename' => '.+'])]
|
||||
public function downloadXmlDump(string $filename): Response
|
||||
{
|
||||
try {
|
||||
if (false === $this->xmlDumpReader->fileExists($filename)) {
|
||||
throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.');
|
||||
}
|
||||
|
||||
$content = $this->xmlDumpReader->getContent($filename);
|
||||
} catch (FilesystemException $e) {
|
||||
throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', 'application/xml');
|
||||
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
basename($filename)
|
||||
);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Admin\User;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
@@ -10,8 +10,10 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class UserController extends AbstractController
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentPricesQuery;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
#[Route('/api')]
|
||||
#[IsGranted('ROLE_OAUTH2_API')]
|
||||
class ContingentController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContingentsClient $contingentsClient,
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly AccommodationPriceRepository $priceRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])]
|
||||
public function prices(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ContingentPricesQuery $query,
|
||||
): JsonResponse {
|
||||
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
|
||||
|
||||
if (null === $accommodation) {
|
||||
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$yearStart = new \DateTimeImmutable("{$query->year}-01-01");
|
||||
$yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd);
|
||||
|
||||
$currency = $accommodation->getCurrency();
|
||||
|
||||
return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency));
|
||||
}
|
||||
|
||||
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
|
||||
public function calendar(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ContingentCalendarQuery $query,
|
||||
): JsonResponse {
|
||||
$dateFrom = $query->dateFromDate();
|
||||
$dateTo = $query->dateToDate();
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
|
||||
|
||||
if (null === $accommodation) {
|
||||
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo);
|
||||
$calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) {
|
||||
$item->expiresAfter(3600);
|
||||
|
||||
return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo);
|
||||
});
|
||||
} catch (BpnConnectException|InvalidArgumentException $e) {
|
||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||
}
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
$currency = $accommodation->getCurrency();
|
||||
|
||||
$data = array_map(
|
||||
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency),
|
||||
$calendar->data,
|
||||
);
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
*
|
||||
* @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null}
|
||||
*/
|
||||
private function enrichEntry(string $date, string $status, array $prices, string $currency): array
|
||||
{
|
||||
$day = (new \DateTimeImmutable($date))->setTime(0, 0);
|
||||
|
||||
// dateFrom and dateTo are both inclusive (last night, not checkout day)
|
||||
$candidates = array_filter(
|
||||
$prices,
|
||||
fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
||||
);
|
||||
|
||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
||||
|
||||
$defaultPrice = null;
|
||||
if (PriceType::DISCOUNT === $winner?->getType()) {
|
||||
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
|
||||
$defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults);
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'status' => $status,
|
||||
'type' => $winner?->getType()?->value,
|
||||
'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null,
|
||||
'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null,
|
||||
'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null,
|
||||
'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
|
||||
'currency' => $currency,
|
||||
'includedPax' => $winner?->getIncludedPax(),
|
||||
'minNights' => $winner?->getMinNights(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@
|
||||
<?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',
|
||||
4 => 'app_groups_booking_step_4',
|
||||
default => 'app_groups_booking_step_1',
|
||||
};
|
||||
|
||||
return $this->redirectToRoute($route);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Form\OfferAcceptConfirmationType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
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
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed-link entry point: validates the `t`/`_hash` query params once,
|
||||
* authorizes the session for this booking, and redirects to the plain
|
||||
* (session-gated) offer page — nothing downstream needs the signature again.
|
||||
*/
|
||||
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET'])]
|
||||
public function access(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');
|
||||
}
|
||||
|
||||
$this->linkSigner->authorizeSession($request, $booking);
|
||||
|
||||
return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]));
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_booking_offer_view', methods: ['GET'])]
|
||||
public function view(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->render('groups/booking/offer_unavailable.html.twig');
|
||||
}
|
||||
|
||||
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||
$priceBreakdown = $this->breakdownCalculator->compute($booking);
|
||||
|
||||
$ctx = new AccommodationBookingContext(
|
||||
accommodation: $accommodation,
|
||||
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
|
||||
priceBreakdown: $priceBreakdown,
|
||||
);
|
||||
|
||||
return $this->render('groups/booking/offer.html.twig', [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $priceBreakdown,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_booking_offer_confirm', methods: ['GET', 'POST'])]
|
||||
public function confirm(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
if (!$booking->isInquiry()) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [
|
||||
'terms_url' => $this->getParameter('terms_and_conditions_url'),
|
||||
]);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||
$this->bookingService->acceptBooking($booking);
|
||||
$this->addFlash('success', 'Deine Buchung ist bestätigt.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [
|
||||
'booking' => $booking,
|
||||
'confirmationForm' => $confirmationForm,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the browser to a full reload of the (non-modal) offer page — this is
|
||||
* triggered from an htmx-loaded modal, so a plain render/redirect here would
|
||||
* get appended as an inert HTML fragment instead of actually navigating.
|
||||
*/
|
||||
private function redirectToOfferPage(Request $request, string $uuid): Response
|
||||
{
|
||||
$url = $this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]);
|
||||
|
||||
return $this->htmxRedirect($request, $url);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\AccommodationStep3Type;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class Step3Controller extends AbstractAccommodationController
|
||||
{
|
||||
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);
|
||||
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()) {
|
||||
$dto->currentStep = 4;
|
||||
$this->sessionManager->save($request, $dto);
|
||||
|
||||
return $this->redirectToRoute('app_groups_booking_step_4');
|
||||
}
|
||||
|
||||
return $this->render('groups/booking/step_3.html.twig', [
|
||||
'dto' => $dto,
|
||||
'ctx' => $ctx,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): AccommodationBookingDto|Response
|
||||
{
|
||||
try {
|
||||
$dto = $this->sessionManager->getOrFail($request);
|
||||
} catch (AccommodationSessionNotFoundException $e) {
|
||||
return $this->createFailureResponse($e, false);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\AccommodationBookingConfirmationType;
|
||||
use App\Form\AccommodationInquiryConfirmationType;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class Step4Controller extends AbstractAccommodationController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationSessionManager $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-4', name: 'app_groups_booking_step_4', methods: ['GET', 'POST'])]
|
||||
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);
|
||||
|
||||
if ($request->isMethod(Request::METHOD_POST)) {
|
||||
$inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class);
|
||||
$inquiryForm->handleRequest($request);
|
||||
|
||||
if (!$inquiryForm->isSubmitted() || !$inquiryForm->isValid()) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$dto->forceInquiry = true;
|
||||
$this->persistBooking($request, $dto, $accommodation, $services);
|
||||
|
||||
return $this->redirectToRoute('app_groups_booking_success');
|
||||
}
|
||||
|
||||
$ctx = new AccommodationBookingContext(
|
||||
accommodation: $accommodation,
|
||||
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
|
||||
boardServices: $services['boardServices'],
|
||||
additionalServices: $services['additionalServices'],
|
||||
priceBreakdown: $dto->priceBreakdown ?: null,
|
||||
);
|
||||
|
||||
return $this->render('groups/booking/step_4.html.twig', [
|
||||
'dto' => $dto,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-4/confirm-inquiry', name: 'app_groups_booking_step_4_confirm_inquiry', methods: ['GET'])]
|
||||
public function confirmInquiry(Request $request): Response
|
||||
{
|
||||
$dto = $this->resolveDto($request, true);
|
||||
if ($dto instanceof Response) {
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class);
|
||||
|
||||
return $this->render('groups/booking/_step4_inquiry_confirmation_modal.html.twig', [
|
||||
'inquiryForm' => $inquiryForm,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-4/confirm', name: 'app_groups_booking_step_4_confirm', methods: ['GET', '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(AccommodationBookingConfirmationType::class, $dto, [
|
||||
'terms_url' => $this->getParameter('terms_and_conditions_url'),
|
||||
]);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||
$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/_step4_booking_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, 4)) {
|
||||
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: BoardService[],
|
||||
* additionalServices: AdditionalService[],
|
||||
* groupedAdditionalServices: array<string, AdditionalService[]>,
|
||||
* ungroupedAdditionalServices: AdditionalService[]
|
||||
* } $services
|
||||
*/
|
||||
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
|
||||
{
|
||||
$prices = $this->bookingService->loadPrices($dto, $accommodation);
|
||||
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
|
||||
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
|
||||
$this->sessionManager->clear($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
trait BlameableEntity
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?User $updatedBy = null;
|
||||
|
||||
public function getCreatedBy(): ?User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(?User $createdBy): self
|
||||
{
|
||||
$this->createdBy = $createdBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedBy(): ?User
|
||||
{
|
||||
return $this->updatedBy;
|
||||
}
|
||||
|
||||
public function setUpdatedBy(?User $updatedBy): self
|
||||
{
|
||||
$this->updatedBy = $updatedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
interface BlameableEntityInterface
|
||||
{
|
||||
public function getCreatedBy(): ?User;
|
||||
|
||||
public function setCreatedBy(?User $createdBy): self;
|
||||
|
||||
public function getUpdatedBy(): ?User;
|
||||
|
||||
public function setUpdatedBy(?User $updatedBy): self;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Entity\BlameableEntity;
|
||||
use App\Entity\BlameableEntityInterface;
|
||||
use App\Entity\TimestampableEntity;
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccommodationRepository::class)]
|
||||
class Accommodation implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'required')]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
#[Assert\NotBlank(message: 'required')]
|
||||
private ?string $calendarCode = null;
|
||||
|
||||
#[ORM\Column(length: 16, nullable: true)]
|
||||
private ?string $cmsCode = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, AccommodationPrice>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AccommodationPrice::class, mappedBy: 'accommodation', cascade: ['all'])]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
|
||||
private Collection $accommodationPrices;
|
||||
|
||||
/**
|
||||
* @var Collection<int, BoardService>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
|
||||
private Collection $boardServices;
|
||||
|
||||
/**
|
||||
* @var Collection<int, AdditionalService>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
|
||||
private Collection $additionalServices;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $maxAdolescentAge = null;
|
||||
|
||||
#[ORM\Column(length: 3)]
|
||||
#[Assert\NotBlank(message: 'required')]
|
||||
#[Assert\Choice(choices: ['EUR', 'CHF'], message: 'invalid')]
|
||||
private ?string $currency = 'EUR';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accommodationPrices = new ArrayCollection();
|
||||
$this->boardServices = new ArrayCollection();
|
||||
$this->additionalServices = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalendarCode(): ?string
|
||||
{
|
||||
return $this->calendarCode;
|
||||
}
|
||||
|
||||
public function setCalendarCode(string $calendarCode): self
|
||||
{
|
||||
$this->calendarCode = $calendarCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCmsCode(): ?string
|
||||
{
|
||||
return $this->cmsCode;
|
||||
}
|
||||
|
||||
public function setCmsCode(?string $cmsCode): self
|
||||
{
|
||||
$this->cmsCode = $cmsCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEffectiveCmsCode(): string
|
||||
{
|
||||
return $this->cmsCode ?? $this->calendarCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AccommodationPrice>
|
||||
*/
|
||||
public function getAccommodationPrices(): Collection
|
||||
{
|
||||
return $this->accommodationPrices;
|
||||
}
|
||||
|
||||
public function addAccommodationPrice(AccommodationPrice $accommodationPrice): self
|
||||
{
|
||||
if (!$this->accommodationPrices->contains($accommodationPrice)) {
|
||||
$this->accommodationPrices->add($accommodationPrice);
|
||||
$accommodationPrice->setAccommodation($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAccommodationPrice(AccommodationPrice $accommodationPrice): self
|
||||
{
|
||||
if ($this->accommodationPrices->removeElement($accommodationPrice)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($accommodationPrice->getAccommodation() === $this) {
|
||||
$accommodationPrice->setAccommodation(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxAdolescentAge(): ?int
|
||||
{
|
||||
return $this->maxAdolescentAge;
|
||||
}
|
||||
|
||||
public function setMaxAdolescentAge(int $maxAdolescentAge): self
|
||||
{
|
||||
$this->maxAdolescentAge = $maxAdolescentAge;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCurrency(): ?string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function setCurrency(string $currency): self
|
||||
{
|
||||
$this->currency = $currency;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, BoardService>
|
||||
*/
|
||||
public function getBoardServices(): Collection
|
||||
{
|
||||
return $this->boardServices;
|
||||
}
|
||||
|
||||
public function addBoardService(BoardService $boardService): self
|
||||
{
|
||||
if (!$this->boardServices->contains($boardService)) {
|
||||
$this->boardServices->add($boardService);
|
||||
$boardService->setAccommodation($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeBoardService(BoardService $boardService): self
|
||||
{
|
||||
if ($this->boardServices->removeElement($boardService)) {
|
||||
if ($boardService->getAccommodation() === $this) {
|
||||
$boardService->setAccommodation(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AdditionalService>
|
||||
*/
|
||||
public function getAdditionalServices(): Collection
|
||||
{
|
||||
return $this->additionalServices;
|
||||
}
|
||||
|
||||
public function addAdditionalService(AdditionalService $additionalService): self
|
||||
{
|
||||
if (!$this->additionalServices->contains($additionalService)) {
|
||||
$this->additionalServices->add($additionalService);
|
||||
$additionalService->setAccommodation($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAdditionalService(AdditionalService $additionalService): self
|
||||
{
|
||||
if ($this->additionalServices->removeElement($additionalService)) {
|
||||
if ($additionalService->getAccommodation() === $this) {
|
||||
$additionalService->setAccommodation(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
<?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\Entity\User;
|
||||
use App\Enum\Groups\AdditionalServiceType;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
|
||||
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;
|
||||
|
||||
/** @var list<array{label: string, price: int, type: string, originalServiceId: int|null}> */
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $additionalServices = [];
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $priceBreakdown = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $totalPrice = null;
|
||||
|
||||
#[ORM\Column(length: 3, nullable: true)]
|
||||
private ?string $pricingCurrency = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
private ?int $pricingVersion = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isInquiry = false;
|
||||
|
||||
#[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\Range(min: 1, max: 100)]
|
||||
private ?int $accommodationDiscount = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\Range(min: 1, max: 100)]
|
||||
private ?int $boardServiceDiscount = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\Range(min: 1, max: 100)]
|
||||
private ?int $additionalServicesDiscount = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?User $managedBy = 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;
|
||||
}
|
||||
|
||||
/** @return list<array{label: string, price: int, type: string, originalServiceId: int|null}> */
|
||||
public function getAdditionalServices(): array
|
||||
{
|
||||
return $this->additionalServices;
|
||||
}
|
||||
|
||||
/** @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServices */
|
||||
public function setAdditionalServices(array $additionalServices): self
|
||||
{
|
||||
$this->additionalServices = $additionalServices;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getPriceBreakdown(): ?array
|
||||
{
|
||||
return $this->priceBreakdown;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $priceBreakdown */
|
||||
public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self
|
||||
{
|
||||
$this->priceBreakdown = $priceBreakdown;
|
||||
$this->totalPrice = $totalPrice;
|
||||
$this->pricingCurrency = $currency;
|
||||
$this->pricingVersion = $version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clearPriceSnapshot(): self
|
||||
{
|
||||
$this->priceBreakdown = null;
|
||||
$this->totalPrice = null;
|
||||
$this->pricingCurrency = null;
|
||||
$this->pricingVersion = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalPrice(): ?int
|
||||
{
|
||||
return $this->totalPrice;
|
||||
}
|
||||
|
||||
public function getPricingCurrency(): ?string
|
||||
{
|
||||
return $this->pricingCurrency;
|
||||
}
|
||||
|
||||
public function getPricingVersion(): ?int
|
||||
{
|
||||
return $this->pricingVersion;
|
||||
}
|
||||
|
||||
public function isInquiry(): bool
|
||||
{
|
||||
return $this->isInquiry;
|
||||
}
|
||||
|
||||
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 getAccommodationDiscount(): ?int
|
||||
{
|
||||
return $this->accommodationDiscount;
|
||||
}
|
||||
|
||||
public function setAccommodationDiscount(?int $accommodationDiscount): self
|
||||
{
|
||||
$this->accommodationDiscount = $accommodationDiscount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBoardServiceDiscount(): ?int
|
||||
{
|
||||
return $this->boardServiceDiscount;
|
||||
}
|
||||
|
||||
public function setBoardServiceDiscount(?int $boardServiceDiscount): self
|
||||
{
|
||||
$this->boardServiceDiscount = $boardServiceDiscount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAdditionalServicesDiscount(): ?int
|
||||
{
|
||||
return $this->additionalServicesDiscount;
|
||||
}
|
||||
|
||||
public function setAdditionalServicesDiscount(?int $additionalServicesDiscount): self
|
||||
{
|
||||
$this->additionalServicesDiscount = $additionalServicesDiscount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getManagedBy(): ?User
|
||||
{
|
||||
return $this->managedBy;
|
||||
}
|
||||
|
||||
public function setManagedBy(?User $managedBy): self
|
||||
{
|
||||
$this->managedBy = $managedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Entity\BlameableEntity;
|
||||
use App\Entity\BlameableEntityInterface;
|
||||
use App\Entity\TimestampableEntity;
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Enum\Groups\Season;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccommodationPriceRepository::class)]
|
||||
class AccommodationPrice implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'accommodationPrices')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[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.
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[ORM\Column(length: 20, enumType: Season::class)]
|
||||
private ?Season $season = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $includedPax = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $pricePerNight = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $priceAdditionalPerson = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $minNights = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true, enumType: PriceType::class)]
|
||||
private ?PriceType $type = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $acceptUndersubscription = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $acceptShortTerm = false;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
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 getSeason(): ?Season
|
||||
{
|
||||
return $this->season;
|
||||
}
|
||||
|
||||
public function setSeason(?Season $season): self
|
||||
{
|
||||
$this->season = $season;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIncludedPax(): ?int
|
||||
{
|
||||
return $this->includedPax;
|
||||
}
|
||||
|
||||
public function setIncludedPax(int $includedPax): self
|
||||
{
|
||||
$this->includedPax = $includedPax;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPricePerNight(): ?int
|
||||
{
|
||||
return $this->pricePerNight;
|
||||
}
|
||||
|
||||
public function setPricePerNight(int $pricePerNight): self
|
||||
{
|
||||
$this->pricePerNight = $pricePerNight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceAdditionalPerson(): ?int
|
||||
{
|
||||
return $this->priceAdditionalPerson;
|
||||
}
|
||||
|
||||
public function setPriceAdditionalPerson(int $priceAdditionalPerson): self
|
||||
{
|
||||
$this->priceAdditionalPerson = $priceAdditionalPerson;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMinNights(): ?int
|
||||
{
|
||||
return $this->minNights;
|
||||
}
|
||||
|
||||
public function setMinNights(int $minNights): self
|
||||
{
|
||||
$this->minNights = $minNights;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?PriceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?PriceType $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAcceptUndersubscription(): bool
|
||||
{
|
||||
return $this->acceptUndersubscription;
|
||||
}
|
||||
|
||||
public function setAcceptUndersubscription(bool $acceptUndersubscription): self
|
||||
{
|
||||
$this->acceptUndersubscription = $acceptUndersubscription;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAcceptShortTerm(): bool
|
||||
{
|
||||
return $this->acceptShortTerm;
|
||||
}
|
||||
|
||||
public function setAcceptShortTerm(bool $acceptShortTerm): self
|
||||
{
|
||||
$this->acceptShortTerm = $acceptShortTerm;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
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\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
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'additionalServices')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
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;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
}
|
||||
|
||||
public function setAccommodation(?Accommodation $accommodation): self
|
||||
{
|
||||
$this->accommodation = $accommodation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): self
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function setPrice(int $price): self
|
||||
{
|
||||
$this->price = $price;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?AdditionalServiceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AdditionalServiceType $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
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 getSelectionGroup(): ?string
|
||||
{
|
||||
return $this->selectionGroup;
|
||||
}
|
||||
|
||||
public function setSelectionGroup(?string $selectionGroup): self
|
||||
{
|
||||
$this->selectionGroup = $selectionGroup;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Entity\BlameableEntity;
|
||||
use App\Entity\BlameableEntityInterface;
|
||||
use App\Entity\TimestampableEntity;
|
||||
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
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'boardServices')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
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;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
}
|
||||
|
||||
public function setAccommodation(?Accommodation $accommodation): self
|
||||
{
|
||||
$this->accommodation = $accommodation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): self
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function setPrice(int $price): self
|
||||
{
|
||||
$this->price = $price;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
trait TimestampableEntity
|
||||
{
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeImmutable $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(\DateTimeImmutable $updatedAt): self
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
interface TimestampableEntityInterface
|
||||
{
|
||||
public function getCreatedAt(): \DateTimeImmutable;
|
||||
|
||||
public function setCreatedAt(\DateTimeImmutable $createdAt): self;
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable;
|
||||
|
||||
public function setUpdatedAt(\DateTimeImmutable $updatedAt): self;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum\Groups;
|
||||
|
||||
enum AdditionalServiceType: string
|
||||
{
|
||||
case Flat = 'flat';
|
||||
case PerPerson = 'per_person';
|
||||
case PerNight = 'per_night';
|
||||
case PerPersonPerNight = 'per_person_per_night';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'enum.additional_service.'.$this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum\Groups;
|
||||
|
||||
enum PriceType: string
|
||||
{
|
||||
case OVERRIDE = 'override';
|
||||
case DISCOUNT = 'discount';
|
||||
|
||||
public function priority(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::OVERRIDE => 1,
|
||||
self::DISCOUNT => 2,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::OVERRIDE => 'Override',
|
||||
self::DISCOUNT => 'Rabatt',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum\Groups;
|
||||
|
||||
enum Season: string
|
||||
{
|
||||
case ADV_SECONDARY = 'adv_secondary';
|
||||
case SECONDARY = 'secondary';
|
||||
case PEAK = 'peak';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'enum.season.'.$this->value.'.label';
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return 'enum.season.'.$this->value.'.token';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\BlameableEntityInterface;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[AsDoctrineListener(Events::prePersist)]
|
||||
#[AsDoctrineListener(Events::preUpdate)]
|
||||
class BlamableEntityListener
|
||||
{
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public function prePersist(PrePersistEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if (!$entity instanceof BlameableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->setCreatedBy($user);
|
||||
}
|
||||
|
||||
public function preUpdate(PreUpdateEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if (!$entity instanceof BlameableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->setUpdatedBy($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
#[AsDoctrineListener(Events::prePersist)]
|
||||
#[AsDoctrineListener(Events::preUpdate)]
|
||||
class TimestampableEntityListener
|
||||
{
|
||||
public function prePersist(PrePersistEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableEntityInterface) {
|
||||
$now = new \DateTimeImmutable();
|
||||
$entity->setCreatedAt($now);
|
||||
}
|
||||
}
|
||||
|
||||
public function preUpdate(PreUpdateEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableEntityInterface) {
|
||||
$now = new \DateTimeImmutable();
|
||||
$entity->setUpdatedAt($now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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 AccommodationBookingConfirmationType 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('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
|
||||
'label_html' => true,
|
||||
'mapped' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationBookingDto::class,
|
||||
]);
|
||||
$resolver->setRequired('terms_url');
|
||||
$resolver->setAllowedTypes('terms_url', 'string');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<null>
|
||||
*/
|
||||
class AccommodationInquiryConfirmationType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'csrf_token_id' => 'groups_booking_step4_inquiry',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,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,175 @@
|
||||
<?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;
|
||||
|
||||
/** @extends AbstractType<AccommodationBooking> */
|
||||
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('accommodationDiscount', IntegerType::class, [
|
||||
'label' => 'Rabatt Unterkunft (%)',
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new Range(min: 1, max: 100),
|
||||
],
|
||||
])
|
||||
->add('boardServiceDiscount', IntegerType::class, [
|
||||
'label' => 'Rabatt Verpflegung (%)',
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new Range(min: 1, max: 100),
|
||||
],
|
||||
])
|
||||
->add('additionalServicesDiscount', IntegerType::class, [
|
||||
'label' => 'Rabatt Zusatzleistungen (%)',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Enum\Groups\Season;
|
||||
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;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/** @extends AbstractType<AccommodationPrice> */
|
||||
class AccommodationPriceType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('type', EnumType::class, [
|
||||
'label' => 'Typ',
|
||||
'class' => PriceType::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'Standard',
|
||||
'choice_label' => fn($item) => $item->label(),
|
||||
])
|
||||
->add('season', EnumType::class, [
|
||||
'label' => 'Saison',
|
||||
'class' => Season::class,
|
||||
'choice_label' => fn($item) => $item->token(),
|
||||
])
|
||||
->add('includedPax', IntegerType::class, [
|
||||
'label' => 'Inklusiv-Personen',
|
||||
])
|
||||
->add('minNights', IntegerType::class, [
|
||||
'label' => 'Mindestbelegung (Nächte)',
|
||||
])
|
||||
->add('pricePerNight', MoneyType::class, [
|
||||
'label' => 'Preis pro Nacht',
|
||||
'currency' => $options['currency'],
|
||||
'divisor' => 100,
|
||||
])
|
||||
->add('priceAdditionalPerson', MoneyType::class, [
|
||||
'label' => 'Preis weitere Person',
|
||||
'currency' => $options['currency'],
|
||||
'divisor' => 100,
|
||||
])
|
||||
->add('acceptUndersubscription', CheckboxType::class, [
|
||||
'label' => 'Unterbelegung akzeptieren',
|
||||
'required' => false,
|
||||
])
|
||||
->add('acceptShortTerm', CheckboxType::class, [
|
||||
'label' => 'Kurzzeit-Belegung akzeptieren',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationPrice::class,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
$resolver->setAllowedValues('currency', ['EUR', 'CHF']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
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\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/** @extends AbstractType<Accommodation> */
|
||||
class AccommodationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('name', TextType::class, [
|
||||
'label' => 'Name',
|
||||
])
|
||||
->add('calendarCode', TextType::class, [
|
||||
'label' => 'Code für Kalender',
|
||||
])
|
||||
->add('cmsCode', TextType::class, [
|
||||
'label' => 'Code für CMS-Daten',
|
||||
'required' => false,
|
||||
])
|
||||
->add('maxAdolescentAge', IntegerType::class, [
|
||||
'label' => 'Altersgrenze Kinder',
|
||||
])
|
||||
->add('currency', ChoiceType::class, [
|
||||
'label' => 'Währung',
|
||||
'choices' => [
|
||||
'EUR' => 'EUR',
|
||||
'CHF' => 'CHF',
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Accommodation::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
|
||||
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;
|
||||
|
||||
/** @extends AbstractType<AdditionalService> */
|
||||
class AdditionalServiceType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->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,
|
||||
'choice_label' => fn($item) => $item->label(),
|
||||
])
|
||||
->add('price', MoneyType::class, [
|
||||
'label' => 'Preis',
|
||||
'currency' => $options['currency'],
|
||||
'divisor' => 100,
|
||||
])
|
||||
->add('selectionGroup', TextType::class, [
|
||||
'label' => 'Auswahlgruppe',
|
||||
'required' => false,
|
||||
'disabled' => true,
|
||||
])
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AdditionalService::class,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
$resolver->setAllowedValues('currency', ['EUR', 'CHF']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\BoardService;
|
||||
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;
|
||||
|
||||
/** @extends AbstractType<BoardService> */
|
||||
class BoardServiceType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->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', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BoardService::class,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
$resolver->setAllowedValues('currency', ['EUR', 'CHF']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/** @extends AbstractType<mixed> */
|
||||
class DatepickerType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'html5' => false,
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'min_date' => null,
|
||||
'max_date' => null,
|
||||
'disable_weekends' => false,
|
||||
]);
|
||||
$resolver->setAllowedTypes('min_date', ['null', \DateTimeImmutable::class]);
|
||||
$resolver->setAllowedTypes('max_date', ['null', \DateTimeImmutable::class]);
|
||||
$resolver->setAllowedTypes('disable_weekends', 'bool');
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['min_date'] = $options['min_date'];
|
||||
$view->vars['max_date'] = $options['max_date'];
|
||||
$view->vars['disable_weekends'] = $options['disable_weekends'];
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return DateType::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ModalSubmitExtension extends AbstractTypeExtension
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'hx_post' => null,
|
||||
'hx_target' => '#htmx-modal',
|
||||
'hx_swap' => 'outerHTML',
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
if (null !== $options['hx_post']) {
|
||||
$attr = [
|
||||
'hx-post' => $options['hx_post'],
|
||||
'hx-target' => $options['hx_target'],
|
||||
'hx-swap' => $options['hx_swap'],
|
||||
'hx-indicator' => '#htmx-modal-indicator',
|
||||
];
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], $attr);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getExtendedTypes(): iterable
|
||||
{
|
||||
return [FormType::class];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\Model\BookingSummaryCmsHotelData;
|
||||
use App\Model\CmsHotelData;
|
||||
|
||||
/**
|
||||
* DTO containing all booking summary data for sidebar display.
|
||||
@@ -19,7 +19,7 @@ class BookingSummaryDto
|
||||
public readonly int $participantCount,
|
||||
public readonly BookingSummaryPricingDto $pricing,
|
||||
public readonly BookingSummaryVoucherDto $vouchers,
|
||||
public readonly ?BookingSummaryCmsHotelData $cmsData,
|
||||
public readonly ?CmsHotelData $cmsData,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
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<null>
|
||||
*/
|
||||
class OfferAcceptConfirmationType 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('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
|
||||
'label_html' => true,
|
||||
'mapped' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setRequired('terms_url');
|
||||
$resolver->setAllowedTypes('terms_url', 'string');
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,27 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'edit',
|
||||
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Gruppenbuchungen', [
|
||||
'route' => 'app_admin_accommodationbooking',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Buchungen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'list',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Gruppenhäuser', [
|
||||
'route' => 'app_admin_accommodation',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Gruppenhäuser',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'house',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Benutzer', [
|
||||
@@ -47,6 +68,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'list',
|
||||
'routes' => [['pattern' => '/^app_admin_log/']],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Menu;
|
||||
|
||||
use Knp\Menu\ItemInterface;
|
||||
|
||||
class GroupsMenuBuilder extends AbstractMenuBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function createMainMenu(array $options): ItemInterface
|
||||
{
|
||||
$menu = $this->createRootElement();
|
||||
|
||||
$menu->addChild('Dashboard', [
|
||||
'route' => 'app_admin_dashboard',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Dashboard',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'chart',
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Gruppenbuchungen', [
|
||||
'route' => 'app_admin_accommodationbooking',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Buchungen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'list',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Gruppenhäuser', [
|
||||
'route' => 'app_admin_accommodation',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Gruppenhäuser',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'house',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->addLogoutItem($menu);
|
||||
|
||||
return $menu;
|
||||
}
|
||||
}
|
||||
@@ -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,115 @@
|
||||
<?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 $accommodationDiscount;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $boardServiceDiscount;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $additionalServicesDiscount;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $totalPrice;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $pricingCurrency;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $pricingVersion;
|
||||
|
||||
/**
|
||||
* @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->accommodationDiscount = $booking->getAccommodationDiscount();
|
||||
$this->boardServiceDiscount = $booking->getBoardServiceDiscount();
|
||||
$this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount();
|
||||
$this->totalPrice = $booking->getTotalPrice();
|
||||
$this->pricingCurrency = $booking->getPricingCurrency();
|
||||
$this->pricingVersion = $booking->getPricingVersion();
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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
|
||||
*/
|
||||
public function __construct(
|
||||
public Accommodation $accommodation,
|
||||
public ?CmsHotelData $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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* Typed hotel CMS payload for the booking summary.
|
||||
*/
|
||||
final readonly class BookingSummaryCmsHotelData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $images
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $name,
|
||||
public ?string $address,
|
||||
public ?array $images,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
final readonly class CalendarDay
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public bool $hasPrice,
|
||||
public bool $hasBoard,
|
||||
public bool $hasAdditional,
|
||||
public bool $isBlocked,
|
||||
public ?string $tooltip,
|
||||
public bool $isToday,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
final readonly class CalendarMonth
|
||||
{
|
||||
/**
|
||||
* @param array<int, CalendarDay> $days keyed by day-of-month number
|
||||
*/
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public int $firstDow,
|
||||
public array $days,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* Typed CMS hotel payload, used both for the rich hotel-details view (booking offer page,
|
||||
* admin accommodation edit) and the slimmer booking-summary sidebar. Fields beyond name,
|
||||
* address and images are only populated when sourced from
|
||||
* {@see \App\Service\CmsDataProvider::getHotelDetails()}.
|
||||
*/
|
||||
final readonly class CmsHotelData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $images
|
||||
* @param array<string, array{label?: string|null, value?: mixed}>|null $icons
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $name,
|
||||
public ?string $address,
|
||||
public ?array $images,
|
||||
public ?string $description = null,
|
||||
public ?string $features = null,
|
||||
public ?string $roomTypes = null,
|
||||
public ?string $additionalInformation = null,
|
||||
public ?array $icons = null,
|
||||
public ?CmsRegionData $region = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* Typed region payload nested within CmsHotelData.
|
||||
*/
|
||||
final readonly class CmsRegionData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $images
|
||||
* @param list<string>|null $regionMaps
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $name,
|
||||
public ?float $latitude,
|
||||
public ?float $longitude,
|
||||
public ?string $webcam,
|
||||
public ?string $skiArea,
|
||||
public ?string $skiAreaExtended,
|
||||
public ?string $description,
|
||||
public ?string $news,
|
||||
public ?int $length,
|
||||
public ?int $altitude,
|
||||
public ?int $lifts,
|
||||
public ?array $images,
|
||||
public ?array $regionMaps,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
final readonly class ContingentCalendarQuery
|
||||
{
|
||||
private const int MAX_RANGE_DAYS = 366;
|
||||
|
||||
public function __construct(
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 16)]
|
||||
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
|
||||
public string $hotelCode,
|
||||
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Date]
|
||||
public string $dateFrom,
|
||||
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Date]
|
||||
public string $dateTo,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateRange(ExecutionContextInterface $context): void
|
||||
{
|
||||
$dateFrom = self::parseDate($this->dateFrom);
|
||||
$dateTo = self::parseDate($this->dateTo);
|
||||
|
||||
if (null === $dateFrom || null === $dateTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dateTo < $dateFrom) {
|
||||
$context->buildViolation('dateTo must not be before dateFrom.')
|
||||
->atPath('dateTo')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dateFrom->diff($dateTo)->days > self::MAX_RANGE_DAYS) {
|
||||
$context->buildViolation(sprintf('The date range must not exceed %d days.', self::MAX_RANGE_DAYS))
|
||||
->atPath('dateTo')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
public function dateFromDate(): \DateTimeImmutable
|
||||
{
|
||||
return self::parseDate($this->dateFrom)
|
||||
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
|
||||
}
|
||||
|
||||
public function dateToDate(): \DateTimeImmutable
|
||||
{
|
||||
return self::parseDate($this->dateTo)
|
||||
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
|
||||
}
|
||||
|
||||
private static function parseDate(string $value): ?\DateTimeImmutable
|
||||
{
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
|
||||
if (false === $date || (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
final readonly class ContingentPricesQuery
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 16)]
|
||||
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
|
||||
public string $hotelCode,
|
||||
|
||||
#[Assert\Range(min: 1000, max: 9999)]
|
||||
public int $year,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
final readonly class PriceTimelineItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $dateFrom,
|
||||
public string $dateTo,
|
||||
public ?string $season,
|
||||
public ?int $includedPax,
|
||||
public float $pricePerNight,
|
||||
public float $priceAdditionalPerson,
|
||||
public ?float $defaultPricePerNight,
|
||||
public ?float $defaultPriceAdditionalPerson,
|
||||
public string $currency,
|
||||
public ?string $type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user