feat: split nights off an accommodation booking as separate periods

addresses #869f6z36v
This commit is contained in:
fromme committed 2026-09-30 18:06:20 +02:00
1 parent 972839286c
commit 7001bc0cf2
35 files changed
+2221 -25

No files matched your search

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** Changes the length and the lines of a split-off period; the main period absorbs the difference. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class EditPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/edit', name: 'app_admin_accommodationbooking_period_edit')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->updatePeriod($booking, $period, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde aktualisiert.');
$this->logger->info('Updated a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $period->getId(),
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to update a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $period->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => 'Zeitraum bearbeiten',
]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\HttpFoundation\Request;
/**
* For the modals opened from the edit form: they save on their own and send the browser back to
* it, rebuilt with the new price.
*/
trait RedirectsToEditFormTrait
{
/**
* The return url is forwarded still encoded, the way return_url() handed it over, so that
* the edit page keeps leading back to the list the booking was opened from.
*/
private function editUrl(AccommodationBooking $booking, Request $request): string
{
$parameters = ['id' => $booking->getId()];
$returnUrl = $request->query->getString('r');
if ('' !== $returnUrl) {
$parameters['r'] = $returnUrl;
}
return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Htmx\HxTrait;
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;
/** Gives a split-off period's nights back to the main period, which reprices the booking. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class RemovePeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/remove', name: 'app_admin_accommodationbooking_period_remove')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('remove_accommodation_booking_period_'.$period->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$periodId = $period->getId();
try {
$violations = $this->bookingService->removePeriod($booking, $period);
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to remove a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $periodId,
]);
$this->addFlash('error', ImplausibleBookingPriceException::FORM_ERROR_MESSAGE);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
// Removing only ever lengthens the main period, so this is reached only when the
// stay's dates were already inconsistent with the periods.
foreach ($violations as $violation) {
$this->addFlash('error', (string) $violation->getMessage());
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde entfernt.');
$this->logger->info('Removed a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $periodId,
]);
}
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
return $this->render('admin/accommodation_booking/modal_remove_period.html.twig', [
'booking' => $booking,
'period' => $period,
'csrf_token_id' => 'remove_accommodation_booking_period_'.$period->getId(),
]);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
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;
/**
* Splits nights off the start or the end of the stay, for a part of the group that arrives
* early or leaves late and is quoted as free-form lines instead of the booking's parameters.
* Each side can be split once; the period can be resized afterwards through its edit modal.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SplitPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route(
'/admin/accommodation-booking/{id}/period/split/{position}',
name: 'app_admin_accommodationbooking_period_split',
requirements: ['position' => 'leading|trailing'],
)]
public function index(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, Request $request): Response
{
// One period per side. The edit page only offers the free sides, so this catches a page
// that was open while somebody else split the same side.
if (!$booking->canSplitOffPeriod($position)) {
$this->addFlash('error', AccommodationBookingPeriodPosition::Leading === $position
? 'Am Anfang wurde bereits ein Zeitraum abgeteilt.'
: 'Am Ende wurde bereits ein Zeitraum abgeteilt.');
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
$dto = new AccommodationBookingPeriodDto(1, [new AccommodationBookingPeriodLineDto()]);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->splitOffPeriod($booking, $position, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde abgeteilt.');
$this->logger->info('Split off a period of accommodation booking', [
'id' => $booking->getId(),
'position' => $position->value,
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to split off a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->renderModal($booking, $position, $form);
}
/** @param FormInterface<AccommodationBookingPeriodDto> $form */
private function renderModal(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => AccommodationBookingPeriodPosition::Leading === $position
? 'Zeitraum am Anfang abteilen'
: 'Zeitraum am Ende abteilen',
]);
}
}
+191 -1
View File
@@ -10,10 +10,13 @@ use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@@ -195,9 +198,21 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?User $managedBy = null;
/**
* Nights split off the start or the end of the stay and quoted as free-form lines, at most
* one on each side. dateFrom and dateTo keep spanning the whole stay; the booking's own
* parameters price only the main period left between the leading and the trailing period.
*
* @var Collection<int, AccommodationBookingPeriod>
*/
#[ORM\OneToMany(targetEntity: AccommodationBookingPeriod::class, mappedBy: 'booking', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
private Collection $periods;
public function __construct()
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->periods = new ArrayCollection();
}
/**
@@ -211,10 +226,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
* The price snapshot, the service snapshots and all four discounts are deliberately carried
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
* the same numbers as the record it came from rather than a re-quote at today's catalog
* prices; saving it through the edit form recomputes it.
* prices; saving it through the edit form recomputes it. The split-off periods belong to
* that quote and are copied along with it.
*/
public function __clone()
{
$periods = $this->periods->toArray();
$this->periods = new ArrayCollection();
foreach ($periods as $period) {
$this->addPeriod(clone $period);
}
$this->id = null;
$this->uuid = Uuid::v4()->toRfc4122();
$this->status = AccommodationBookingStatus::Draft;
@@ -302,6 +324,174 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
}
}
/**
* At most one period on each side, attached to its edge of the stay, and at least one night
* left for the main period in between. A change to the stay's dates that no longer fits the
* periods is reported on that date rather than silently moving the periods along.
*/
#[Assert\Callback]
public function validatePeriods(ExecutionContextInterface $context): void
{
if (null === $this->dateFrom || null === $this->dateTo || $this->periods->isEmpty()) {
return;
}
foreach (AccommodationBookingPeriodPosition::cases() as $position) {
if (count($this->periodsAt($position)) > 1) {
$context->buildViolation('Es kann nur einen Zeitraum am Anfang und einen am Ende geben.')
->addViolation();
return;
}
}
$leading = $this->getLeadingPeriod();
if (null !== $leading && (!self::sameDay($leading->getDateFrom(), $this->dateFrom) || $leading->getNights() < 1)) {
$context->buildViolation('Der Zeitraum am Anfang passt nicht mehr zur Anreise — bitte Anreise oder Zeitraum prüfen.')
->atPath('dateFrom')
->addViolation();
return;
}
$trailing = $this->getTrailingPeriod();
if (null !== $trailing && (!self::sameDay($trailing->getDateTo(), $this->dateTo) || $trailing->getNights() < 1)) {
$context->buildViolation('Der Zeitraum am Ende passt nicht mehr zur Abreise — bitte Abreise oder Zeitraum prüfen.')
->atPath('dateTo')
->addViolation();
return;
}
if ($this->getMainNights() < 1) {
$context->buildViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.')
->atPath('dateTo')
->addViolation();
}
}
/** @return Collection<int, AccommodationBookingPeriod> */
public function getPeriods(): Collection
{
return $this->periods;
}
public function addPeriod(AccommodationBookingPeriod $period): self
{
if (!$this->periods->contains($period)) {
$this->periods->add($period);
$period->setBooking($this);
}
return $this;
}
public function canSplitOffPeriod(AccommodationBookingPeriodPosition $position): bool
{
return [] === $this->periodsAt($position);
}
/** Splits $nights off the main period, at the edge of the stay that $position names. */
public function splitOffPeriod(AccommodationBookingPeriodPosition $position, int $nights): AccommodationBookingPeriod
{
if (null === $this->dateFrom || null === $this->dateTo) {
throw new \LogicException('A period can only be split off a booking with dates.');
}
if (!$this->canSplitOffPeriod($position)) {
throw new \LogicException(sprintf('The booking already has a %s period.', $position->value));
}
$period = new AccommodationBookingPeriod($position, $this->dateFrom, $this->dateTo);
$this->addPeriod($period);
$this->resizePeriod($period, $nights);
return $period;
}
/** Gives $period a new length, keeping it at its edge of the stay; the main period absorbs the difference. */
public function resizePeriod(AccommodationBookingPeriod $period, int $nights): self
{
if (null === $this->dateFrom || null === $this->dateTo) {
return $this;
}
$period->isLeading()
? $period->setDates($this->dateFrom, $this->dateFrom->modify(sprintf('+%d days', $nights)))
: $period->setDates($this->dateTo->modify(sprintf('-%d days', $nights)), $this->dateTo);
return $this;
}
/** Its nights go back to the main period. */
public function removePeriod(AccommodationBookingPeriod $period): self
{
if ($this->periods->removeElement($period) && $period->getBooking() === $this) {
$period->setBooking(null);
}
return $this;
}
public function getLeadingPeriod(): ?AccommodationBookingPeriod
{
return $this->periodsAt(AccommodationBookingPeriodPosition::Leading)[0] ?? null;
}
public function getTrailingPeriod(): ?AccommodationBookingPeriod
{
return $this->periodsAt(AccommodationBookingPeriodPosition::Trailing)[0] ?? null;
}
/** Arrival of the group the booking's own parameters price: the stay's, unless a period leads it. */
public function getMainDateFrom(): ?\DateTimeImmutable
{
return $this->getLeadingPeriod()?->getDateTo() ?? $this->dateFrom;
}
/** Departure of the group the booking's own parameters price: the stay's, unless a period trails it. */
public function getMainDateTo(): ?\DateTimeImmutable
{
return $this->getTrailingPeriod()?->getDateFrom() ?? $this->dateTo;
}
public function getMainNights(): int
{
$from = $this->getMainDateFrom();
$to = $this->getMainDateTo();
if (null === $from || null === $to || $from >= $to) {
return 0;
}
return $from->diff($to)->days;
}
/** In minor units, the sum of every line of every split-off period. */
public function getPeriodsTotal(): int
{
$total = 0;
foreach ($this->periods as $period) {
$total += $period->getTotal();
}
return $total;
}
/** @return list<AccommodationBookingPeriod> */
private function periodsAt(AccommodationBookingPeriodPosition $position): array
{
return array_values(array_filter(
$this->periods->toArray(),
fn (AccommodationBookingPeriod $p) => $p->getPosition() === $position,
));
}
private static function sameDay(\DateTimeImmutable $a, \DateTimeImmutable $b): bool
{
return $a->format('Y-m-d') === $b->format('Y-m-d');
}
public function getPaxCount(): int
{
return $this->paxCount;
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* A stretch of nights split off the start or the end of a stay, such as the day an advance team
* arrives before the group. It carries no pax, board or services of its own and is never priced
* from the catalog: the office quotes it as free-form lines, which are added to the booking's
* total and fall under its fixed total discount. The booking's own parameters price only what
* remains between the leading and the trailing periods.
*/
#[ORM\Entity]
class AccommodationBookingPeriod implements TimestampableParentAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'periods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?AccommodationBooking $booking = null;
#[ORM\Column(length: 10, enumType: AccommodationBookingPeriodPosition::class)]
private AccommodationBookingPeriodPosition $position;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $dateFrom;
// Exclusive, like the booking's: the departure day, not the last night.
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $dateTo;
/** What the price breakdown calls the period, e.g. "Vorausteam"; without one it shows the dates alone. */
#[ORM\Column(length: 255, nullable: true)]
private ?string $label = null;
/**
* Amounts in minor units of the booking's pricing currency; a line has no currency of its own.
*
* The column is not called `lines`: that is a reserved word in MariaDB, and the ORM does
* not quote column names in the statements it writes.
*
* @var list<array{label: string, amount: int}>
*/
#[ORM\Column(name: 'price_lines', type: Types::JSON)]
private array $lines = [];
public function __construct(AccommodationBookingPeriodPosition $position, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo)
{
$this->position = $position;
$this->dateFrom = $dateFrom;
$this->dateTo = $dateTo;
}
public function __clone()
{
$this->id = null;
$this->booking = null;
}
public function getId(): ?int
{
return $this->id;
}
public function getBooking(): ?AccommodationBooking
{
return $this->booking;
}
public function setBooking(?AccommodationBooking $booking): self
{
$this->booking = $booking;
return $this;
}
public function getTimestampableParent(): ?TimestampableEntityInterface
{
return $this->booking;
}
public function getPosition(): AccommodationBookingPeriodPosition
{
return $this->position;
}
public function isLeading(): bool
{
return AccommodationBookingPeriodPosition::Leading === $this->position;
}
public function getDateFrom(): \DateTimeImmutable
{
return $this->dateFrom;
}
public function getDateTo(): \DateTimeImmutable
{
return $this->dateTo;
}
public function setDates(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): self
{
$this->dateFrom = $dateFrom;
$this->dateTo = $dateTo;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): self
{
$this->label = $label;
return $this;
}
public function getNights(): int
{
return $this->dateFrom < $this->dateTo ? $this->dateFrom->diff($this->dateTo)->days : 0;
}
/** @return list<array{label: string, amount: int}> */
public function getLines(): array
{
return $this->lines;
}
/** @param list<array{label: string, amount: int}> $lines */
public function setLines(array $lines): self
{
$this->lines = $lines;
return $this;
}
public function getTotal(): int
{
return array_sum(array_column($this->lines, 'amount'));
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Enum\Groups;
/**
* Which edge of the stay a split-off period sits at. Periods only ever come off the start or
* the end, so the main period — the one the booking's own parameters price — always stays a
* single contiguous range between them.
*/
enum AccommodationBookingPeriodPosition: string
{
case Leading = 'leading';
case Trailing = 'trailing';
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<AccommodationBookingPeriodLineDto> */
class AccommodationBookingPeriodLineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TextType::class, [
'label' => 'Bezeichnung',
])
->add('amount', MoneyType::class, [
'label' => 'Betrag',
'currency' => $options['currency'],
'divisor' => 100,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingPeriodLineDto::class,
'empty_data' => static fn () => new AccommodationBookingPeriodLineDto(),
'currency' => 'EUR',
]);
$resolver->setAllowedTypes('currency', 'string');
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Form\Model\AccommodationBookingPeriodDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
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<AccommodationBookingPeriodDto> */
class AccommodationBookingPeriodType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TextType::class, [
'label' => 'Bezeichnung',
'required' => false,
'help' => 'Überschrift in der Preisaufstellung, z. B. „Vorausteam“. Ohne Bezeichnung steht dort nur der Zeitraum.',
])
->add('nights', IntegerType::class, [
'label' => 'Nächte',
'attr' => ['min' => 1],
])
->add('lines', CollectionType::class, [
'label' => 'Positionen',
'entry_type' => AccommodationBookingPeriodLineType::class,
'entry_options' => ['label' => false, 'currency' => $options['currency']],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingPeriodDto::class,
'currency' => 'EUR',
]);
$resolver->setAllowedTypes('currency', 'string');
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use Symfony\Component\Validator\Constraints as Assert;
/**
* What the split and edit modals submit for a split-off period. Whether the group keeps a
* night for itself depends on the booking, so that is left to the booking's own validation.
*/
class AccommodationBookingPeriodDto
{
/**
* @param array<int, AccommodationBookingPeriodLineDto> $lines keyed as the form submits them, so not necessarily a list
*/
public function __construct(
#[Assert\NotNull(message: 'Bitte die Anzahl der Nächte angeben.')]
#[Assert\Range(min: 1, max: AccommodationBooking::MAX_NIGHTS)]
public ?int $nights = 1,
#[Assert\Valid]
public array $lines = [],
#[Assert\Length(max: 255)]
public ?string $label = null,
) {
}
public static function fromPeriod(AccommodationBookingPeriod $period): self
{
return new self(
$period->getNights(),
array_map(
fn (array $line) => new AccommodationBookingPeriodLineDto($line['label'], $line['amount']),
$period->getLines(),
),
$period->getLabel(),
);
}
/** @return list<array{label: string, amount: int}> */
public function linesSnapshot(): array
{
return array_values(array_map(
fn (AccommodationBookingPeriodLineDto $line) => ['label' => (string) $line->label, 'amount' => (int) $line->amount],
$this->lines,
));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
/** One free-form price line of a split-off period, as the office keys it in. */
class AccommodationBookingPeriodLineDto
{
public function __construct(
#[Assert\NotBlank(message: 'Bitte eine Bezeichnung angeben.')]
#[Assert\Length(max: 255)]
public ?string $label = null,
/** In minor units of the booking's pricing currency. */
#[Assert\NotNull(message: 'Bitte einen Betrag angeben.')]
#[Assert\PositiveOrZero]
public ?int $amount = null,
) {
}
}
@@ -26,7 +26,15 @@ class AccommodationBookingBreakdownCalculator
{
$breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking);
return null !== $breakdown ? $this->withDiscounts($booking, $breakdown) : null;
if (null === $breakdown) {
return null;
}
// Snapshots frozen before split-off periods existed (pricing version 1) read as a
// booking without any, so templates only ever handle one shape.
$breakdown += ['periods' => [], 'periodsTotal' => 0];
return $this->withDiscounts($booking, $breakdown);
}
/**
@@ -100,6 +108,10 @@ class AccommodationBookingBreakdownCalculator
* Calculates against the current catalog. Use only while creating or explicitly editing
* a booking, or as a compatibility fallback for records created before price snapshots.
*
* The catalog prices the main period only; the split-off periods add their lines on top,
* so `total` is the grand total the discounts are taken from. The price rows are loaded for
* the whole stay, which the includedPax floor and the short-term surcharge are judged on.
*
* @return array<string, mixed>|null
*/
public function computeCurrent(AccommodationBooking $booking): ?array
@@ -118,16 +130,40 @@ class AccommodationBookingBreakdownCalculator
$dateTo,
);
return $this->priceCalculator->calculateFromSnapshots(
$breakdown = $this->priceCalculator->calculateFromSnapshots(
$booking->getPaxCount(),
$booking->getMinorsCount(),
$booking->getNights(),
$booking->getMainNights(),
$booking->getMainDateFrom() ?? $dateFrom,
$booking->getMainDateTo() ?? $dateTo,
$dateFrom,
$dateTo,
$booking->getNights(),
$prices,
$booking->getBoardServicePrice(),
$booking->getAdditionalServices(),
$accommodation->getCurrency(),
);
$periods = [];
foreach (array_filter([$booking->getLeadingPeriod(), $booking->getTrailingPeriod()]) as $period) {
$periods[] = [
'position' => $period->getPosition()->value,
'label' => $period->getLabel(),
'dateFrom' => $period->getDateFrom()->format('Y-m-d'),
'dateTo' => $period->getDateTo()->format('Y-m-d'),
'nights' => $period->getNights(),
'lines' => $period->getLines(),
'total' => $period->getTotal(),
];
}
$periodsTotal = $booking->getPeriodsTotal();
return [
...$breakdown,
'periods' => $periods,
'periodsTotal' => $periodsTotal,
'total' => $breakdown['total'] + $periodsTotal,
];
}
}
+57 -1
View File
@@ -8,13 +8,16 @@ use App\Email\EmailAttachmentInterface;
use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -25,11 +28,16 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AccommodationBookingService
{
private const int PRICING_VERSION = 1;
/**
* 2: the breakdown carries the split-off `periods` and `periodsTotal`, and `total` includes
* them. Version 1 snapshots are read as bookings without periods.
*/
private const int PRICING_VERSION = 2;
public function __construct(
private readonly AccommodationRepository $accommodationRepo,
@@ -381,6 +389,54 @@ class AccommodationBookingService
);
}
/**
* The three period changes below each take effect at once, like the house change: they
* reprice the booking, and the office wants to see the new price before it goes on.
* On violations nothing is written and the violations are returned for the modal.
*
* @throws ImplausibleBookingPriceException if the new total is too large to be a real price
*/
public function splitOffPeriod(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
{
$booking->splitOffPeriod($position, (int) $dto->nights)
->setLabel($dto->label)
->setLines($dto->linesSnapshot());
return $this->commitPeriodChange($booking);
}
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
public function updatePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
{
$booking->resizePeriod($period, (int) $dto->nights);
$period->setLabel($dto->label)->setLines($dto->linesSnapshot());
return $this->commitPeriodChange($booking);
}
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
public function removePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period): ConstraintViolationListInterface
{
$booking->removePeriod($period);
return $this->commitPeriodChange($booking);
}
private function commitPeriodChange(AccommodationBooking $booking): ConstraintViolationListInterface
{
// Default only: the `edit` group asks for contact data, which a period change neither
// shows nor touches, and which the edit form already enforces where it belongs.
$violations = $this->validator->validate($booking, null, ['Default']);
if (count($violations) > 0) {
return $violations;
}
$this->refreshPriceSnapshot($booking);
$this->entityManager->flush();
return $violations;
}
public function sendNotificationEmail(AccommodationBooking $booking): void
{
$this->sendBookingEmail(
+21 -6
View File
@@ -86,6 +86,8 @@ class GroupsPriceCalculator
$nights,
$dateFrom,
$dateTo,
$dateFrom,
$nights,
$prices,
$boardService?->getPrice(),
array_map(
@@ -104,7 +106,12 @@ class GroupsPriceCalculator
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
* instead of live catalog entities.
*
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* $dateFrom/$dateTo/$nights are the nights being priced, which is the main period of a booking
* with split-off periods. $stayDateFrom and $stayNights describe the whole stay, which the
* includedPax floor and the short-term surcharge are judged on, so that splitting nights off
* never changes either.
*
* @param AccommodationPrice[] $prices all prices overlapping the whole stay
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
*
* @return array{
@@ -131,6 +138,8 @@ class GroupsPriceCalculator
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
\DateTimeImmutable $stayDateFrom,
int $stayNights,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalServiceSnapshots,
@@ -151,6 +160,8 @@ class GroupsPriceCalculator
$nights,
$dateFrom,
$dateTo,
$stayDateFrom,
$stayNights,
$prices,
$boardServicePricePerPersonNight,
$items,
@@ -186,15 +197,19 @@ class GroupsPriceCalculator
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
\DateTimeImmutable $stayDateFrom,
int $stayNights,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalItems,
string $currency,
): array {
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax.
// The floor comes from the stay's first night, not the priced range's, so that splitting
// nights off the start cannot swap in another price row's floor.
$firstCandidates = array_values(array_filter(
$prices,
fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
fn (AccommodationPrice $p) => $p->getDateFrom() <= $stayDateFrom && $p->getDateTo() >= $stayDateFrom,
));
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
@@ -239,10 +254,10 @@ class GroupsPriceCalculator
}
}
// Rule 3: short-term surcharge
// Rule 3: short-term surcharge, judged on the whole stay but charged on the priced nights
$shortTermSurcharge = 0;
if (isset(self::SHORT_TERM_FACTORS[$nights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100);
if (isset(self::SHORT_TERM_FACTORS[$stayNights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$stayNights] / 100);
}
// Board is only billable — and only triggers the catering surcharge — when it costs