feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
@@ -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();
}
}
+30
View File
@@ -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,
) {
}
}
-21
View File
@@ -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,
) {
}
}
+19
View File
@@ -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,
) {
}
}
+18
View File
@@ -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,
) {
}
}
+31
View File
@@ -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,
) {
}
}
+32
View File
@@ -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,
) {
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final readonly class ContingentCalendarQuery
{
private const int MAX_RANGE_DAYS = 366;
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateFrom,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateTo,
) {
}
#[Assert\Callback]
public function validateRange(ExecutionContextInterface $context): void
{
$dateFrom = self::parseDate($this->dateFrom);
$dateTo = self::parseDate($this->dateTo);
if (null === $dateFrom || null === $dateTo) {
return;
}
if ($dateTo < $dateFrom) {
$context->buildViolation('dateTo must not be before dateFrom.')
->atPath('dateTo')
->addViolation();
return;
}
if ($dateFrom->diff($dateTo)->days > self::MAX_RANGE_DAYS) {
$context->buildViolation(sprintf('The date range must not exceed %d days.', self::MAX_RANGE_DAYS))
->atPath('dateTo')
->addViolation();
}
}
public function dateFromDate(): \DateTimeImmutable
{
return self::parseDate($this->dateFrom)
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
}
public function dateToDate(): \DateTimeImmutable
{
return self::parseDate($this->dateTo)
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
}
private static function parseDate(string $value): ?\DateTimeImmutable
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = \DateTimeImmutable::getLastErrors();
if (false === $date || (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
return null;
}
return $date->format('Y-m-d') === $value ? $date : null;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ContingentPricesQuery
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\Range(min: 1000, max: 9999)]
public int $year,
) {
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class InquiryStatus
{
/** @param string[] $reasons */
public function __construct(
public bool $isInquiry,
public array $reasons,
) {
}
}
+22
View File
@@ -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,
) {
}
}