feat: split nights off an accommodation booking as separate periods
addresses #869f6z36v
This commit is contained in:
1 parent
972839286c
commit
7001bc0cf2
35 files changed
+2221
-25
No files matched your search
@@ -555,14 +555,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
"totalDiscountLabel": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 3,
|
||||
"pricingVersion": 2,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted.
|
||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
|
||||
|
||||
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
|
||||
|
||||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||
|
||||
@@ -570,6 +572,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
|
||||
|
||||
| field | meaning |
|
||||
| --- | --- |
|
||||
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
|
||||
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
|
||||
| `periodsTotal` | the sum of all `periods[].total` |
|
||||
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
|
||||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
|
||||
* priced exactly as before.
|
||||
*/
|
||||
final class Version20260930150445 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
|
||||
$this->addSql('DROP TABLE accommodation_booking_period');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,48 @@
|
||||
{{ form_row(form.minorsCount) }}
|
||||
{{ form_row(form.childrenCount) }}
|
||||
|
||||
{# Saved by their own modals, which reprice the booking on the spot — see SplitPeriodController. #}
|
||||
<div class="lg:col-span-2">
|
||||
<h3 class="font-bold pb-1">Zeiträume</h3>
|
||||
<p class="text-xs text-gray-500 pb-2">
|
||||
Anreise und Abreise umfassen den ganzen Aufenthalt. Abgeteilte Zeiträume, etwa für ein
|
||||
Vorausteam, werden mit eigenen Positionen berechnet; Personen und Leistungen oben gelten
|
||||
nur für den Zeitraum der Gruppe.
|
||||
</p>
|
||||
<table class="w-full text-sm">
|
||||
{% if booking.leadingPeriod %}
|
||||
{% set p = booking.leadingPeriod %}
|
||||
{{ block('period_row') }}
|
||||
{% endif %}
|
||||
<tr class="border-t border-gray-200">
|
||||
<td class="py-1 whitespace-nowrap">{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}</td>
|
||||
<td class="py-1 whitespace-nowrap">{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}</td>
|
||||
<td class="py-1" colspan="2">Gruppe</td>
|
||||
<td class="py-1 text-right">
|
||||
{# One period per side, so only the free sides are offered. #}
|
||||
{% if booking.leadingPeriod is null or booking.trailingPeriod is null %}
|
||||
<twig:dropdown>
|
||||
{% if booking.leadingPeriod is null %}
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'leading' })) }}">
|
||||
am Anfang abteilen
|
||||
</twig:dropdown:hxbutton>
|
||||
{% endif %}
|
||||
{% if booking.trailingPeriod is null %}
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'trailing' })) }}">
|
||||
am Ende abteilen
|
||||
</twig:dropdown:hxbutton>
|
||||
{% endif %}
|
||||
</twig:dropdown>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if booking.trailingPeriod %}
|
||||
{% set p = booking.trailingPeriod %}
|
||||
{{ block('period_row') }}
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if form.boardService is defined or form.selectedAdditionalServices is defined %}
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Leistungen</h2>
|
||||
{% if form.boardService is defined %}
|
||||
@@ -98,3 +140,37 @@
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block period_row %}
|
||||
{% set period_params = { id: p.id } %}
|
||||
{% if app.request.query.get('r') %}
|
||||
{% set period_params = period_params | merge({ r: app.request.query.get('r') }) %}
|
||||
{% endif %}
|
||||
<tr class="border-t border-gray-200 align-top">
|
||||
<td class="py-1 whitespace-nowrap">{{ p.dateFrom | date('d.m.Y') }} – {{ p.dateTo | date('d.m.Y') }}</td>
|
||||
<td class="py-1 whitespace-nowrap">{{ p.nights }} {{ p.nights != 1 ? 'Nächte' : 'Nacht' }}</td>
|
||||
<td class="py-1">
|
||||
{% if p.label %}
|
||||
<div class="font-medium">{{ p.label }}</div>
|
||||
{% endif %}
|
||||
{% for line in p.lines %}
|
||||
<div>{{ line.label }}</div>
|
||||
{% else %}
|
||||
<div class="text-gray-500">keine Positionen</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">
|
||||
{{ (p.total / 100) | format_currency(booking.pricingCurrency ?? booking.accommodation.currency) }}
|
||||
</td>
|
||||
<td class="py-1 text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_edit', period_params) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:hxbutton>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_remove', period_params) }}" warning>
|
||||
entfernen
|
||||
</twig:dropdown:hxbutton>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends 'htmx_modal_admin.html.twig' %}
|
||||
{% form_theme form 'forms_admin.html.twig' %}
|
||||
|
||||
{% macro line_row(line) %}
|
||||
<div class="flex items-start gap-2" data-form-collection-target="field">
|
||||
<div class="flex-1">{{ form_row(line.label) }}</div>
|
||||
<div class="w-40">{{ form_row(line.amount) }}</div>
|
||||
<button type="button"
|
||||
class="mt-9 text-gray-500 hover:text-red-500"
|
||||
data-action="form-collection#removeItem"
|
||||
title="Position entfernen">
|
||||
{{ icon('close', 'w-5 h-5') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as period %}
|
||||
<div class="text-xl font-bold pb-4">
|
||||
{{ title }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">
|
||||
Die Nächte werden vom Zeitraum der Gruppe abgezogen, der neu berechnet wird. Die Positionen
|
||||
kommen zum Gesamtpreis hinzu und fallen nur unter den Rabatt auf den Gesamtpreis.
|
||||
</p>
|
||||
{{ form_start(form) }}
|
||||
{{ form_errors(form) }}
|
||||
<div class="pb-4">
|
||||
{{ form_row(form.label) }}
|
||||
</div>
|
||||
<div class="w-40 pb-4">
|
||||
{{ form_row(form.nights) }}
|
||||
</div>
|
||||
{% set prototype %}{{ period.line_row(form.lines.vars.prototype) }}{% endset %}
|
||||
<div {{ stimulus_controller('form-collection', { prototype: prototype|json_encode }) }}>
|
||||
<div class="font-bold pb-2">Positionen</div>
|
||||
{{ form_errors(form.lines) }}
|
||||
<div class="space-y-2" data-form-collection-target="fields">
|
||||
{% for line in form.lines %}
|
||||
{{ period.line_row(line) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="button button--secondary button--small mt-2"
|
||||
data-action="form-collection#addItem">
|
||||
Position hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{% do form.lines.setRendered %}
|
||||
<div class="flex justify-end pt-8">
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Zeitraum entfernen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
Möchtest du den Zeitraum {{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }}
|
||||
mit allen Positionen entfernen? Die Nächte gehen an den Zeitraum der Gruppe zurück, und der
|
||||
Preis wird neu berechnet.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}entfernen{% endblock %}
|
||||
@@ -43,6 +43,13 @@
|
||||
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
|
||||
<dt class="font-medium">Nächte</dt>
|
||||
<dd>{{ booking.nights }}</dd>
|
||||
{% if booking.periods is not empty %}
|
||||
<dt class="font-medium">Zeitraum Gruppe</dt>
|
||||
<dd>
|
||||
{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
|
||||
{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt class="font-medium">Anzahl Personen</dt>
|
||||
<dd>{{ booking.paxCount }}</dd>
|
||||
{% if booking.minorsCount > 0 %}
|
||||
@@ -89,6 +96,17 @@
|
||||
<h2 class="text-lg font-bold mb-4">Preise</h2>
|
||||
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
||||
<table class="w-full text-sm">
|
||||
{% for period in priceBreakdown.periods | filter(p => p.position == 'leading') %}
|
||||
{{ block('period_rows') }}
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.periods is not empty %}
|
||||
<tr class="border-t border-gray-200 font-medium">
|
||||
<td class="pt-2 pb-1" colspan="2">
|
||||
Gruppe · {{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
|
||||
{{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">
|
||||
@@ -139,6 +157,9 @@
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for period in priceBreakdown.periods | filter(p => p.position == 'trailing') %}
|
||||
{{ block('period_rows') }}
|
||||
{% endfor %}
|
||||
<tr class="border-t border-gray-200 font-semibold">
|
||||
<td class="pt-2">
|
||||
Gesamtpreis
|
||||
@@ -304,3 +325,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# One split-off period of the breakdown: its range as a heading, then its lines. #}
|
||||
{% block period_rows %}
|
||||
<tr class="border-t border-gray-200 font-medium">
|
||||
<td class="pt-2 pb-1" colspan="2">
|
||||
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||
{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }},
|
||||
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% for line in period.lines %}
|
||||
<tr>
|
||||
<td class="py-1">{{ line.label }}</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (line.amount / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,17 @@
|
||||
<h2>Preise</h2>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
||||
{% for period in priceBreakdown.periods|filter(p => p.position == 'leading') %}
|
||||
{% include 'email/_price_breakdown_period.html.twig' %}
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.periods is not empty %}
|
||||
<tr>
|
||||
<td colspan="2" style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||
Gruppe · {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }},
|
||||
{{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td style="{{ cell }}">
|
||||
@@ -64,6 +75,9 @@
|
||||
<td style="{{ amount }}">{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for period in priceBreakdown.periods|filter(p => p.position == 'trailing') %}
|
||||
{% include 'email/_price_breakdown_period.html.twig' %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||
Gesamtpreis
|
||||
@@ -103,3 +117,4 @@
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{#- One split-off period of _price_breakdown.html.twig: its range as a heading, then its lines.
|
||||
Context: period, cell, amount, currency. -#}
|
||||
<tr>
|
||||
<td colspan="2" style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||
{{ period.dateFrom|date('d.m.Y') }} – {{ period.dateTo|date('d.m.Y') }},
|
||||
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% for line in period.lines %}
|
||||
<tr>
|
||||
<td style="{{ cell }}">{{ line.label }}</td>
|
||||
<td style="{{ amount }}">{{ (line.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -19,6 +19,12 @@
|
||||
<td><strong>Nächte</strong></td>
|
||||
<td>{{ booking.nights }}</td>
|
||||
</tr>
|
||||
{% if booking.periods is not empty %}
|
||||
<tr>
|
||||
<td><strong>Zeitraum Gruppe</strong></td>
|
||||
<td>{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><strong>Anzahl Personen</strong></td>
|
||||
<td>{{ booking.paxCount }}</td>
|
||||
|
||||
@@ -67,6 +67,16 @@
|
||||
{{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if ctx.priceBreakdown.periods is not empty %}
|
||||
<tr>
|
||||
<td class="p-2">Zeitraum Gruppe</td>
|
||||
<td class="p-2 text-right">
|
||||
{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }},
|
||||
<br>
|
||||
{{ ctx.priceBreakdown.nights }} {{ ctx.priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Anzahl Personen
|
||||
@@ -88,6 +98,14 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% for period in ctx.priceBreakdown.periods|filter(p => p.position == 'leading') %}
|
||||
{% include 'groups/offer/_summary_period.html.twig' %}
|
||||
{% endfor %}
|
||||
{% if ctx.priceBreakdown.periods is not empty %}
|
||||
<tr class="border-t border-primary-bg font-semibold">
|
||||
<td class="p-2" colspan="2">Gruppe</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
@@ -141,6 +159,9 @@
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for period in ctx.priceBreakdown.periods|filter(p => p.position == 'trailing') %}
|
||||
{% include 'groups/offer/_summary_period.html.twig' %}
|
||||
{% endfor %}
|
||||
<tr class="border-t border-primary-bg font-semibold text-base">
|
||||
<td class="p-2">
|
||||
Gesamtpreis
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{#- One split-off period of the offer summary: its range as a heading, then its lines.
|
||||
Context: period, currency. -#}
|
||||
<tr class="border-t border-primary-bg font-semibold">
|
||||
<td class="p-2" colspan="2">
|
||||
{% if period.label ?? null %}{{ period.label }} · {% endif %}
|
||||
{{ period.dateFrom|date('d.m.Y') }} – {{ period.dateTo|date('d.m.Y') }},
|
||||
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% for line in period.lines %}
|
||||
<tr>
|
||||
<td class="p-2">{{ line.label }}</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (line.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -83,6 +83,9 @@
|
||||
<tr><th>Anreise</th><td>{{ booking.dateFrom|date('d.m.Y') }}</td></tr>
|
||||
<tr><th>Abreise</th><td>{{ booking.dateTo|date('d.m.Y') }}</td></tr>
|
||||
<tr><th>Nächte</th><td>{{ booking.nights }}</td></tr>
|
||||
{% if booking.periods is not empty %}
|
||||
<tr><th>Zeitraum Gruppe</th><td>{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}</td></tr>
|
||||
{% endif %}
|
||||
<tr><th>Anzahl Personen</th><td>{{ booking.paxCount }}</td></tr>
|
||||
{% if booking.minorsCount > 0 %}
|
||||
<tr><th>davon Kinder (0–3 J.)</th><td>{{ booking.minorsCount }}</td></tr>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\EditPeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/** The modal's error handling is shared with SplitPeriodController and covered there. */
|
||||
class EditPeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testAValidSubmitUpdatesThePeriodOfItsOwnBooking(): void
|
||||
{
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('updatePeriod')
|
||||
->with($booking, $period)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn(true);
|
||||
$form->method('isValid')->willReturn(true);
|
||||
|
||||
$controller = new TestableEditPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||
|
||||
$response = $controller->index($period, Request::create('/admin/accommodation-booking-period/1/edit', 'POST', server: ['HTTP_HX-Request' => 'true']));
|
||||
|
||||
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||
{
|
||||
$controller = new TestableEditPeriodController(
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
$this->createStub(FormInterface::class),
|
||||
);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->index(
|
||||
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||
Request::create('/admin/accommodation-booking-period/1/edit'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableEditPeriodController extends EditPeriodController
|
||||
{
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingService $bookingService,
|
||||
LoggerInterface $logger,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($bookingService, $logger);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\RemovePeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
class RemovePeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testGetAsksForConfirmation(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('removePeriod');
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$controller->index($this->period(), $this->request());
|
||||
|
||||
self::assertSame('admin/accommodation_booking/modal_remove_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testConfirmingRemovesThePeriodAndReturnsToTheEditForm(): void
|
||||
{
|
||||
$period = $this->period();
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('removePeriod')
|
||||
->with($period->getBooking(), $period)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($period, $this->request('POST'));
|
||||
|
||||
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAnInvalidTokenRemovesNothing(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('removePeriod');
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
$controller->tokenValid = false;
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
|
||||
$controller->index($this->period(), $this->request('POST'));
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||
{
|
||||
$controller = new TestableRemovePeriodController($this->createStub(AccommodationBookingService::class), $this->createStub(LoggerInterface::class));
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->index(
|
||||
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||
$this->request(),
|
||||
);
|
||||
}
|
||||
|
||||
private function period(): AccommodationBookingPeriod
|
||||
{
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
|
||||
return $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
}
|
||||
|
||||
private function request(string $method = 'GET'): Request
|
||||
{
|
||||
return Request::create('/admin/accommodation-booking-period/1/remove', $method, server: ['HTTP_HX-Request' => 'true']);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableRemovePeriodController extends RemovePeriodController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
public bool $tokenValid = true;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||
{
|
||||
return $this->tokenValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\SplitPeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Exception\ImplausibleBookingPriceException;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Covers how the modal answers — the split itself is tested in AccommodationBookingServiceTest
|
||||
* and AccommodationBookingTest.
|
||||
*/
|
||||
class SplitPeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersTheModal(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: false));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testASideThatWasAlreadySplitIsNotSplitAgain(): void
|
||||
{
|
||||
// The edit page only offers free sides; this is a page left open while the side was taken.
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||
|
||||
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
self::assertNull($controller->renderedView);
|
||||
self::assertSame('error', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAValidSplitSendsTheBrowserBackToTheEditForm(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('splitOffPeriod')
|
||||
->with($booking, AccommodationBookingPeriodPosition::Trailing)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||
|
||||
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Trailing, $this->request('POST'));
|
||||
|
||||
self::assertSame(
|
||||
'/app_admin_accommodationbooking_edit?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||
$response->headers->get('HX-Redirect'),
|
||||
);
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAViolationOfTheBookingIsReportedInTheModal(): void
|
||||
{
|
||||
$violations = new ConstraintViolationList([
|
||||
new ConstraintViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.', null, [], null, 'dateTo', null),
|
||||
]);
|
||||
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||
$bookingService->method('splitOffPeriod')->willReturn($violations);
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertFalse($response->headers->has('HX-Redirect'), 'the modal stays open on the error');
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
self::assertSame([], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testAnImplausibleTotalIsReportedInTheModal(): void
|
||||
{
|
||||
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||
$bookingService->method('splitOffPeriod')
|
||||
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertFalse($response->headers->has('HX-Redirect'));
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
private function request(string $method = 'GET'): Request
|
||||
{
|
||||
return Request::create(
|
||||
'/admin/accommodation-booking/1/period/split/leading?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||
$method,
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
}
|
||||
|
||||
private function form(bool $submitted, bool $expectsError = false): FormInterface
|
||||
{
|
||||
$form = $expectsError ? $this->createMock(FormInterface::class) : $this->createStub(FormInterface::class);
|
||||
if ($form instanceof \PHPUnit\Framework\MockObject\MockObject) {
|
||||
$form->expects(self::once())->method('addError');
|
||||
}
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn($submitted);
|
||||
$form->method('isValid')->willReturn(true);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
private function buildController(AccommodationBookingService $bookingService, FormInterface $form): TestableSplitPeriodController
|
||||
{
|
||||
return new TestableSplitPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableSplitPeriodController extends SplitPeriodController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingService $bookingService,
|
||||
LoggerInterface $logger,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($bookingService, $logger);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Entity\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Exception\ImplausibleBookingPriceException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class AccommodationBookingTest extends TestCase
|
||||
{
|
||||
@@ -209,6 +213,197 @@ class AccommodationBookingTest extends TestCase
|
||||
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
||||
}
|
||||
|
||||
public function testACopyCarriesItsOwnSplitOffPeriods(): void
|
||||
{
|
||||
// The periods are part of the quote being varied, but editing them on the copy must
|
||||
// not reach back into the original.
|
||||
$booking = $this->confirmedBooking();
|
||||
$booking->addPeriod((new AccommodationBookingPeriod(
|
||||
AccommodationBookingPeriodPosition::Leading,
|
||||
new \DateTimeImmutable('2026-03-01'),
|
||||
new \DateTimeImmutable('2026-03-02'),
|
||||
))->setLines([['label' => 'Vorausteam', 'amount' => 30000]]));
|
||||
$original = $booking->getPeriods()->first();
|
||||
|
||||
$copy = clone $booking;
|
||||
$copied = $copy->getPeriods()->first();
|
||||
|
||||
self::assertCount(1, $copy->getPeriods());
|
||||
self::assertNotSame($original, $copied);
|
||||
self::assertSame($copy, $copied->getBooking());
|
||||
self::assertNull($copied->getId());
|
||||
self::assertSame($original->getLines(), $copied->getLines());
|
||||
self::assertSame($booking, $original->getBooking());
|
||||
}
|
||||
|
||||
public function testWithoutPeriodsTheMainPeriodIsTheWholeStay(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-01'), $booking->getMainDateFrom());
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-08'), $booking->getMainDateTo());
|
||||
self::assertSame(7, $booking->getMainNights());
|
||||
self::assertSame(0, $booking->getPeriodsTotal());
|
||||
}
|
||||
|
||||
public function testTheMainPeriodIsWhatRemainsBetweenTheSplitOffPeriods(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 2000));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 3000));
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-03'), $booking->getMainDateFrom());
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-07'), $booking->getMainDateTo());
|
||||
self::assertSame(4, $booking->getMainNights());
|
||||
self::assertSame(7, $booking->getNights(), 'the stay itself is unchanged');
|
||||
self::assertSame(5000, $booking->getPeriodsTotal());
|
||||
}
|
||||
|
||||
public function testPeriodsThatTileTheEdgesOfTheStayAreValid(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||
|
||||
self::assertCount(0, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testMovingTheArrivalAwayFromALeadingPeriodIsReportedOnTheArrival(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-02', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateFrom', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testASecondPeriodOnTheSameSideIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testMovingTheDepartureAwayFromATrailingPeriodIsReportedOnTheDeparture(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-09');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testTheGroupMustKeepAtLeastOneNight(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-03');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-02', '2026-03-03', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutANightIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-01', 0));
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testSplittingOffAttachesThePeriodToItsEdgeOfTheStay(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
|
||||
$first = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||
$last = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
self::assertSame(['2026-03-01', '2026-03-03'], $this->range($first));
|
||||
self::assertSame(['2026-03-07', '2026-03-08'], $this->range($last));
|
||||
self::assertSame(4, $booking->getMainNights());
|
||||
self::assertCount(0, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testEachSideCanOnlyBeSplitOnce(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
self::assertFalse($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Trailing));
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
}
|
||||
|
||||
public function testResizingKeepsAPeriodAtItsEdge(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-10');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
$booking->resizePeriod($period, 3);
|
||||
|
||||
self::assertSame(['2026-03-07', '2026-03-10'], $this->range($period));
|
||||
self::assertSame(6, $booking->getMainNights());
|
||||
}
|
||||
|
||||
public function testRemovingAPeriodGivesItsNightsBackAndFreesTheSide(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||
|
||||
$booking->removePeriod($period);
|
||||
|
||||
self::assertNull($period->getBooking());
|
||||
self::assertSame(7, $booking->getMainNights());
|
||||
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||
}
|
||||
|
||||
public function testGrowingAPeriodIntoTheLastNightOfTheGroupIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-04');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
$booking->resizePeriod($period, 3);
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
/** @return array{string, string} */
|
||||
private function range(AccommodationBookingPeriod $period): array
|
||||
{
|
||||
return [$period->getDateFrom()->format('Y-m-d'), $period->getDateTo()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
private function stay(string $from, string $to): AccommodationBooking
|
||||
{
|
||||
return (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable($from))
|
||||
->setDateTo(new \DateTimeImmutable($to));
|
||||
}
|
||||
|
||||
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, int $amount): AccommodationBookingPeriod
|
||||
{
|
||||
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
||||
->setLines([['label' => 'Vorausteam', 'amount' => $amount]]);
|
||||
}
|
||||
|
||||
private function validate(AccommodationBooking $booking): ConstraintViolationListInterface
|
||||
{
|
||||
return Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
->validate($booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* A record that has been all the way through the lifecycle, so a copy of it has the most
|
||||
* to reset.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Model;
|
||||
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationBookingPeriodDtoTest extends TestCase
|
||||
{
|
||||
public function testTheEditModalStartsFromWhatThePeriodHolds(): void
|
||||
{
|
||||
$period = (new AccommodationBookingPeriod(
|
||||
AccommodationBookingPeriodPosition::Leading,
|
||||
new \DateTimeImmutable('2026-07-01'),
|
||||
new \DateTimeImmutable('2026-07-03'),
|
||||
))
|
||||
->setLabel('Vorausteam')
|
||||
->setLines([['label' => 'Übernachtung', 'amount' => 25000]]);
|
||||
|
||||
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
|
||||
|
||||
self::assertSame('Vorausteam', $dto->label);
|
||||
self::assertSame(2, $dto->nights);
|
||||
self::assertSame([['label' => 'Übernachtung', 'amount' => 25000]], $dto->linesSnapshot());
|
||||
}
|
||||
|
||||
public function testTheLinesAreStoredAsAListWhateverKeysTheFormSubmitted(): void
|
||||
{
|
||||
// A collection form keeps the indexes of the rows it was sent, so after a removed row
|
||||
// the keys have gaps — the JSON column must still get a list, not an object.
|
||||
$dto = new AccommodationBookingPeriodDto(1, [
|
||||
2 => new AccommodationBookingPeriodLineDto('Übernachtung', 25000),
|
||||
5 => new AccommodationBookingPeriodLineDto('Verpflegung', 6000),
|
||||
]);
|
||||
|
||||
self::assertSame([
|
||||
['label' => 'Übernachtung', 'amount' => 25000],
|
||||
['label' => 'Verpflegung', 'amount' => 6000],
|
||||
], $dto->linesSnapshot());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
@@ -190,6 +193,124 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
||||
self::assertArrayNotHasKey('discountedTotal', $breakdown);
|
||||
}
|
||||
|
||||
public function testAVersion1SnapshotReadsAsABookingWithoutPeriods(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1);
|
||||
|
||||
$breakdown = $this->createCalculator()->compute($booking);
|
||||
|
||||
self::assertSame([], $breakdown['periods']);
|
||||
self::assertSame(0, $breakdown['periodsTotal']);
|
||||
self::assertSame(12345, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
public function testWithoutPeriodsTheCatalogPricesTheWholeStay(): void
|
||||
{
|
||||
// Regression guard: a booking nobody split is priced exactly as before periods existed.
|
||||
$booking = $this->stay();
|
||||
|
||||
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
|
||||
|
||||
self::assertSame(7, $breakdown['nights']);
|
||||
self::assertSame(7 * 100_00, $breakdown['basePrice']);
|
||||
self::assertSame(7 * 100_00, $breakdown['total']);
|
||||
self::assertSame([], $breakdown['periods']);
|
||||
self::assertSame(0, $breakdown['periodsTotal']);
|
||||
}
|
||||
|
||||
public function testTheCatalogPricesOnlyTheMainPeriodAndThePeriodLinesAreAddedOnTop(): void
|
||||
{
|
||||
$booking = $this->stay();
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-07-07', '2026-07-08', [['label' => 'Abbau', 'amount' => 100_00]]));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [
|
||||
['label' => 'Übernachtung', 'amount' => 250_00],
|
||||
['label' => 'Verpflegung', 'amount' => 50_00],
|
||||
])->setLabel('Vorausteam'));
|
||||
|
||||
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
|
||||
|
||||
self::assertSame(5, $breakdown['nights']);
|
||||
self::assertSame(5 * 100_00, $breakdown['basePrice']);
|
||||
self::assertSame(400_00, $breakdown['periodsTotal']);
|
||||
self::assertSame(5 * 100_00 + 400_00, $breakdown['total']);
|
||||
self::assertSame([
|
||||
[
|
||||
'position' => 'leading',
|
||||
'label' => 'Vorausteam',
|
||||
'dateFrom' => '2026-07-01',
|
||||
'dateTo' => '2026-07-02',
|
||||
'nights' => 1,
|
||||
'lines' => [
|
||||
['label' => 'Übernachtung', 'amount' => 250_00],
|
||||
['label' => 'Verpflegung', 'amount' => 50_00],
|
||||
],
|
||||
'total' => 300_00,
|
||||
],
|
||||
[
|
||||
'position' => 'trailing',
|
||||
'label' => null,
|
||||
'dateFrom' => '2026-07-07',
|
||||
'dateTo' => '2026-07-08',
|
||||
'nights' => 1,
|
||||
'lines' => [['label' => 'Abbau', 'amount' => 100_00]],
|
||||
'total' => 100_00,
|
||||
],
|
||||
], $breakdown['periods']);
|
||||
}
|
||||
|
||||
public function testOnlyTheFixedDiscountReachesThePeriodLines(): void
|
||||
{
|
||||
// The section discounts belong to the catalog-priced main period; the fixed discount
|
||||
// is taken off the grand total, so it may exceed what the main period alone costs.
|
||||
$booking = $this->stay();
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [['label' => 'Vorausteam', 'amount' => 1000_00]]));
|
||||
$booking->setAccommodationDiscount(10);
|
||||
$booking->setTotalDiscountAmount(900_00);
|
||||
$booking->setTotalDiscountLabel('Sonderpreis');
|
||||
$calculator = $this->createCalculator($this->priceRepository());
|
||||
|
||||
$breakdown = $calculator->withDiscounts($booking, $calculator->computeCurrent($booking));
|
||||
|
||||
self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 60_00]], $breakdown['discounts']);
|
||||
self::assertSame(600_00 + 1000_00 - 60_00, $breakdown['discountSubtotal']);
|
||||
self::assertSame(600_00 + 1000_00 - 60_00 - 900_00, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
private function stay(): AccommodationBooking
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('EUR'));
|
||||
$booking->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
||||
$booking->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
$booking->setPaxCount(20);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
/** @param list<array{label: string, amount: int}> $lines */
|
||||
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, array $lines): AccommodationBookingPeriod
|
||||
{
|
||||
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
||||
->setLines($lines);
|
||||
}
|
||||
|
||||
private function priceRepository(): AccommodationPriceRepository
|
||||
{
|
||||
$price = (new AccommodationPrice())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-06-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-08-31'))
|
||||
->setIncludedPax(20)
|
||||
->setPricePerNight(100_00)
|
||||
->setPriceAdditionalPerson(10_00)
|
||||
->setMinNights(1);
|
||||
|
||||
$priceRepository = $this->createStub(AccommodationPriceRepository::class);
|
||||
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn([$price]);
|
||||
|
||||
return $priceRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingPdfGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -27,6 +28,37 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
||||
self::assertSame(1, $this->pageCount($pdf));
|
||||
}
|
||||
|
||||
public function testADocumentWithSplitOffPeriodsStillFitsOnASingleA4Page(): void
|
||||
{
|
||||
$booking = $this->booking();
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1)->setLabel('Vorausteam')->setLines([
|
||||
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
|
||||
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
|
||||
]);
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1)->setLines([
|
||||
['label' => 'Abbau', 'amount' => 10000],
|
||||
]);
|
||||
|
||||
$breakdown = [
|
||||
...$this->breakdown(),
|
||||
'nights' => 3,
|
||||
'periods' => [
|
||||
['position' => 'leading', 'label' => 'Vorausteam', 'dateFrom' => '2026-07-06', 'dateTo' => '2026-07-07', 'nights' => 1, 'lines' => [
|
||||
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
|
||||
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
|
||||
], 'total' => 31000],
|
||||
['position' => 'trailing', 'dateFrom' => '2026-07-10', 'dateTo' => '2026-07-11', 'nights' => 1, 'lines' => [
|
||||
['label' => 'Abbau', 'amount' => 10000],
|
||||
], 'total' => 10000],
|
||||
],
|
||||
'periodsTotal' => 41000,
|
||||
];
|
||||
|
||||
$pdf = $this->createGenerator($breakdown)->render($booking);
|
||||
|
||||
self::assertSame(1, $this->pageCount($pdf));
|
||||
}
|
||||
|
||||
public function testTheDownloadIsAnAttachmentNamedAfterArrivalDateAndHotel(): void
|
||||
{
|
||||
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
|
||||
@@ -122,6 +154,8 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
||||
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
|
||||
'servicesPrice' => 28800,
|
||||
'runningCosts' => 16000,
|
||||
'periods' => [],
|
||||
'periodsTotal' => 0,
|
||||
'total' => 844800,
|
||||
'currency' => 'EUR',
|
||||
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
|
||||
|
||||
@@ -10,10 +10,13 @@ use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
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\Form\Model\AccommodationBookingDto;
|
||||
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||
use App\Model\AccommodationBookingQueryParams;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
@@ -951,7 +954,86 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
self::assertSame($breakdown, $booking->getPriceBreakdown(), 'the derived keys must not be persisted');
|
||||
self::assertSame(10895, $booking->getTotalPrice());
|
||||
self::assertSame('CHF', $booking->getPricingCurrency());
|
||||
self::assertSame(1, $booking->getPricingVersion());
|
||||
self::assertSame(2, $booking->getPricingVersion());
|
||||
}
|
||||
|
||||
public function testSplittingOffAPeriodRepricesAndWritesTheBooking(): void
|
||||
{
|
||||
$booking = $this->periodBooking();
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->expects(self::once())->method('computeCurrent')->with($booking)->willReturn(null);
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
|
||||
$violations = $service->splitOffPeriod(
|
||||
$booking,
|
||||
AccommodationBookingPeriodPosition::Leading,
|
||||
new AccommodationBookingPeriodDto(1, [3 => new AccommodationBookingPeriodLineDto('Übernachtung', 30000)], 'Vorausteam'),
|
||||
);
|
||||
|
||||
self::assertCount(0, $violations);
|
||||
self::assertSame('Vorausteam', $booking->getLeadingPeriod()?->getLabel());
|
||||
self::assertSame([['label' => 'Übernachtung', 'amount' => 30000]], $booking->getLeadingPeriod()?->getLines());
|
||||
self::assertSame(6, $booking->getMainNights());
|
||||
}
|
||||
|
||||
public function testASplitThatLeavesTheGroupNoNightIsNeitherPricedNorWritten(): void
|
||||
{
|
||||
$booking = $this->periodBooking();
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->expects(self::never())->method('computeCurrent');
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
|
||||
$violations = $service->splitOffPeriod($booking, AccommodationBookingPeriodPosition::Trailing, new AccommodationBookingPeriodDto(7));
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
}
|
||||
|
||||
public function testUpdatingAPeriodChangesItsLengthAndLines(): void
|
||||
{
|
||||
$booking = $this->periodBooking();
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
$violations = $service->updatePeriod($booking, $period, new AccommodationBookingPeriodDto(2, [new AccommodationBookingPeriodLineDto('Abbau', 5000)]));
|
||||
|
||||
self::assertCount(0, $violations);
|
||||
self::assertSame(2, $period->getNights());
|
||||
self::assertSame([['label' => 'Abbau', 'amount' => 5000]], $period->getLines());
|
||||
self::assertSame(5, $booking->getMainNights());
|
||||
}
|
||||
|
||||
public function testRemovingAPeriodGivesItsNightsBackToTheGroup(): void
|
||||
{
|
||||
$booking = $this->periodBooking();
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
$violations = $service->removePeriod($booking, $period);
|
||||
|
||||
self::assertCount(0, $violations);
|
||||
self::assertTrue($booking->getPeriods()->isEmpty());
|
||||
self::assertSame(7, $booking->getMainNights());
|
||||
}
|
||||
|
||||
private function periodBooking(): AccommodationBooking
|
||||
{
|
||||
return (new AccommodationBooking())
|
||||
->setAccommodation((new Accommodation())->setCalendarCode('HOTEL'))
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'))
|
||||
->setPaxCount(20);
|
||||
}
|
||||
|
||||
public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void
|
||||
|
||||
@@ -48,6 +48,97 @@ class GroupsPriceCalculatorTest extends TestCase
|
||||
self::assertSame(50, $result['undersubscriptionThreshold']);
|
||||
}
|
||||
|
||||
public function testTheShortTermSurchargeIsJudgedOnTheWholeStay(): void
|
||||
{
|
||||
// Three nights priced for the group, but one night split off a four-night stay: the
|
||||
// stay is not short-term, so the three nights must not pick up the 10 % surcharge.
|
||||
$price = $this->price('2026-06-01', '2026-08-31', 20);
|
||||
|
||||
$split = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-01', 4, [$price]);
|
||||
$unsplit = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-02', 3, [$price]);
|
||||
|
||||
self::assertSame(0, $split['shortTermSurcharge']);
|
||||
self::assertSame(3, $split['nights']);
|
||||
self::assertSame((int) round(($unsplit['basePrice'] + $unsplit['additionalPersonsPrice']) * 10 / 100), $unsplit['shortTermSurcharge']);
|
||||
}
|
||||
|
||||
public function testTheShortTermSurchargeOfAShortStayIsChargedOnThePricedNightsOnly(): void
|
||||
{
|
||||
// A three-night stay with one night split off: 10 % on the two nights the group pays
|
||||
// for, not 20 % for a two-night stay and not on the split-off night's lines.
|
||||
$price = $this->price('2026-06-01', '2026-08-31', 20);
|
||||
|
||||
$result = $this->calculateMain('2026-07-02', '2026-07-04', '2026-07-01', 3, [$price]);
|
||||
|
||||
self::assertSame(2 * 100_00, $result['basePrice']);
|
||||
self::assertSame((int) round(($result['basePrice'] + $result['additionalPersonsPrice']) * 10 / 100), $result['shortTermSurcharge']);
|
||||
}
|
||||
|
||||
public function testTheIncludedPaxFloorComesFromTheStaysFirstNight(): void
|
||||
{
|
||||
// The stay starts in a price period with a floor of 30; the main group arrives a day
|
||||
// later, in one with a floor of 50. Splitting the first night off must not raise the
|
||||
// floor the 20 people are billed at.
|
||||
$prices = [
|
||||
$this->price('2026-06-01', '2026-07-01', 30),
|
||||
$this->price('2026-07-02', '2026-08-31', 50),
|
||||
];
|
||||
|
||||
$result = $this->calculateMain('2026-07-02', '2026-07-08', '2026-07-01', 7, $prices, paxCount: 20, boardServicePrice: 1500);
|
||||
|
||||
self::assertSame(30, $result['includedPax']);
|
||||
self::assertSame(30, $result['effectivePax']);
|
||||
self::assertSame(1500 * 30 * 6, $result['boardPrice']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AccommodationPrice> $prices
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function calculateMain(string $from, string $to, string $stayFrom, int $stayNights, array $prices, int $paxCount = 20, ?int $boardServicePrice = null): array
|
||||
{
|
||||
$dateFrom = new \DateTimeImmutable($from);
|
||||
$dateTo = new \DateTimeImmutable($to);
|
||||
|
||||
return $this->calculator()->calculateFromSnapshots(
|
||||
$paxCount,
|
||||
0,
|
||||
$dateFrom->diff($dateTo)->days,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
new \DateTimeImmutable($stayFrom),
|
||||
$stayNights,
|
||||
$prices,
|
||||
$boardServicePrice,
|
||||
[],
|
||||
'EUR',
|
||||
);
|
||||
}
|
||||
|
||||
private function price(string $from, string $to, int $includedPax): AccommodationPrice
|
||||
{
|
||||
return (new AccommodationPrice())
|
||||
->setDateFrom(new \DateTimeImmutable($from))
|
||||
->setDateTo(new \DateTimeImmutable($to))
|
||||
->setIncludedPax($includedPax)
|
||||
->setPricePerNight(100_00)
|
||||
->setPriceAdditionalPerson(10_00)
|
||||
->setMinNights(1);
|
||||
}
|
||||
|
||||
private function calculator(): GroupsPriceCalculator
|
||||
{
|
||||
return new GroupsPriceCalculator(new PriceTimelineBuilder(), [
|
||||
'runningCostsEur' => 0,
|
||||
'runningCostsChf' => 0,
|
||||
'undersubscription30Eur' => 5.0,
|
||||
'undersubscription30Chf' => 6.0,
|
||||
'undersubscription40Eur' => 2.5,
|
||||
'undersubscription40Chf' => 3.0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -64,21 +155,14 @@ class GroupsPriceCalculatorTest extends TestCase
|
||||
->setPriceAdditionalPerson(10_00)
|
||||
->setMinNights(1);
|
||||
|
||||
$calculator = new GroupsPriceCalculator(new PriceTimelineBuilder(), [
|
||||
'runningCostsEur' => 0,
|
||||
'runningCostsChf' => 0,
|
||||
'undersubscription30Eur' => 5.0,
|
||||
'undersubscription30Chf' => 6.0,
|
||||
'undersubscription40Eur' => 2.5,
|
||||
'undersubscription40Chf' => 3.0,
|
||||
]);
|
||||
|
||||
return $calculator->calculateFromSnapshots(
|
||||
return $this->calculator()->calculateFromSnapshots(
|
||||
$paxCount,
|
||||
0,
|
||||
self::NIGHTS,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$dateFrom,
|
||||
self::NIGHTS,
|
||||
[$price],
|
||||
$boardServicePrice,
|
||||
[],
|
||||
|
||||
Reference in new issue
Block a user