From 7001bc0cf22530eed31d90e38d881b583f168fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 30 Sep 2026 17:42:21 +0200 Subject: [PATCH] feat: split nights off an accommodation booking as separate periods addresses #869f6z36v --- docs/api-consumer-guide.md | 10 +- migrations/Version20260930150445.php | 32 +++ .../EditPeriodController.php | 81 ++++++++ .../RedirectsToEditFormTrait.php | 30 +++ .../RemovePeriodController.php | 80 +++++++ .../SplitPeriodController.php | 107 ++++++++++ src/Entity/Groups/AccommodationBooking.php | 192 ++++++++++++++++- .../Groups/AccommodationBookingPeriod.php | 155 ++++++++++++++ .../AccommodationBookingPeriodPosition.php | 14 ++ .../AccommodationBookingPeriodLineType.php | 40 ++++ .../Groups/AccommodationBookingPeriodType.php | 50 +++++ .../Model/AccommodationBookingPeriodDto.php | 51 +++++ .../AccommodationBookingPeriodLineDto.php | 22 ++ ...ccommodationBookingBreakdownCalculator.php | 44 +++- src/Service/AccommodationBookingService.php | 58 +++++- src/Service/GroupsPriceCalculator.php | 27 ++- .../accommodation_booking/edit.html.twig | 76 +++++++ .../modal_period.html.twig | 57 +++++ .../modal_remove_period.html.twig | 13 ++ .../accommodation_booking/show.html.twig | 38 ++++ templates/email/_price_breakdown.html.twig | 15 ++ .../email/_price_breakdown_period.html.twig | 15 ++ templates/email/_stay_details.html.twig | 6 + templates/groups/offer/_summary.html.twig | 21 ++ .../groups/offer/_summary_period.html.twig | 15 ++ templates/pdf/accommodation_booking.html.twig | 3 + .../EditPeriodControllerTest.php | 103 +++++++++ .../RemovePeriodControllerTest.php | 127 ++++++++++++ .../SplitPeriodControllerTest.php | 180 ++++++++++++++++ .../Groups/AccommodationBookingTest.php | 195 ++++++++++++++++++ .../AccommodationBookingPeriodDtoTest.php | 46 +++++ ...modationBookingBreakdownCalculatorTest.php | 121 +++++++++++ .../AccommodationBookingPdfGeneratorTest.php | 34 +++ .../AccommodationBookingServiceTest.php | 84 +++++++- tests/Service/GroupsPriceCalculatorTest.php | 104 +++++++++- 35 files changed, 2221 insertions(+), 25 deletions(-) create mode 100644 migrations/Version20260930150445.php create mode 100644 src/Controller/Admin/AccommodationBooking/EditPeriodController.php create mode 100644 src/Controller/Admin/AccommodationBooking/RedirectsToEditFormTrait.php create mode 100644 src/Controller/Admin/AccommodationBooking/RemovePeriodController.php create mode 100644 src/Controller/Admin/AccommodationBooking/SplitPeriodController.php create mode 100644 src/Entity/Groups/AccommodationBookingPeriod.php create mode 100644 src/Enum/Groups/AccommodationBookingPeriodPosition.php create mode 100644 src/Form/Admin/Groups/AccommodationBookingPeriodLineType.php create mode 100644 src/Form/Admin/Groups/AccommodationBookingPeriodType.php create mode 100644 src/Form/Model/AccommodationBookingPeriodDto.php create mode 100644 src/Form/Model/AccommodationBookingPeriodLineDto.php create mode 100644 templates/admin/accommodation_booking/modal_period.html.twig create mode 100644 templates/admin/accommodation_booking/modal_remove_period.html.twig create mode 100644 templates/email/_price_breakdown_period.html.twig create mode 100644 templates/groups/offer/_summary_period.html.twig create mode 100644 tests/Controller/Admin/AccommodationBooking/EditPeriodControllerTest.php create mode 100644 tests/Controller/Admin/AccommodationBooking/RemovePeriodControllerTest.php create mode 100644 tests/Controller/Admin/AccommodationBooking/SplitPeriodControllerTest.php create mode 100644 tests/Form/Model/AccommodationBookingPeriodDtoTest.php diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 4a4e1a91..29c191bf 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -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 | diff --git a/migrations/Version20260930150445.php b/migrations/Version20260930150445.php new file mode 100644 index 00000000..f87d8886 --- /dev/null +++ b/migrations/Version20260930150445.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/EditPeriodController.php b/src/Controller/Admin/AccommodationBooking/EditPeriodController.php new file mode 100644 index 00000000..33269f08 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/EditPeriodController.php @@ -0,0 +1,81 @@ +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', + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/RedirectsToEditFormTrait.php b/src/Controller/Admin/AccommodationBooking/RedirectsToEditFormTrait.php new file mode 100644 index 00000000..93df0aeb --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/RedirectsToEditFormTrait.php @@ -0,0 +1,30 @@ + $booking->getId()]; + $returnUrl = $request->query->getString('r'); + if ('' !== $returnUrl) { + $parameters['r'] = $returnUrl; + } + + return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/RemovePeriodController.php b/src/Controller/Admin/AccommodationBooking/RemovePeriodController.php new file mode 100644 index 00000000..ed2baa05 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/RemovePeriodController.php @@ -0,0 +1,80 @@ +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(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/SplitPeriodController.php b/src/Controller/Admin/AccommodationBooking/SplitPeriodController.php new file mode 100644 index 00000000..dfb82861 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/SplitPeriodController.php @@ -0,0 +1,107 @@ + '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 $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', + ]); + } +} diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index dfac1e10..012430ab 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -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 + */ + #[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 */ + 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 */ + 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; diff --git a/src/Entity/Groups/AccommodationBookingPeriod.php b/src/Entity/Groups/AccommodationBookingPeriod.php new file mode 100644 index 00000000..92b75ac1 --- /dev/null +++ b/src/Entity/Groups/AccommodationBookingPeriod.php @@ -0,0 +1,155 @@ + + */ + #[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 */ + public function getLines(): array + { + return $this->lines; + } + + /** @param list $lines */ + public function setLines(array $lines): self + { + $this->lines = $lines; + + return $this; + } + + public function getTotal(): int + { + return array_sum(array_column($this->lines, 'amount')); + } +} diff --git a/src/Enum/Groups/AccommodationBookingPeriodPosition.php b/src/Enum/Groups/AccommodationBookingPeriodPosition.php new file mode 100644 index 00000000..0f75448c --- /dev/null +++ b/src/Enum/Groups/AccommodationBookingPeriodPosition.php @@ -0,0 +1,14 @@ + */ +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'); + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingPeriodType.php b/src/Form/Admin/Groups/AccommodationBookingPeriodType.php new file mode 100644 index 00000000..10a896ae --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationBookingPeriodType.php @@ -0,0 +1,50 @@ + */ +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'); + } +} diff --git a/src/Form/Model/AccommodationBookingPeriodDto.php b/src/Form/Model/AccommodationBookingPeriodDto.php new file mode 100644 index 00000000..a954b43a --- /dev/null +++ b/src/Form/Model/AccommodationBookingPeriodDto.php @@ -0,0 +1,51 @@ + $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 */ + public function linesSnapshot(): array + { + return array_values(array_map( + fn (AccommodationBookingPeriodLineDto $line) => ['label' => (string) $line->label, 'amount' => (int) $line->amount], + $this->lines, + )); + } +} diff --git a/src/Form/Model/AccommodationBookingPeriodLineDto.php b/src/Form/Model/AccommodationBookingPeriodLineDto.php new file mode 100644 index 00000000..00858239 --- /dev/null +++ b/src/Form/Model/AccommodationBookingPeriodLineDto.php @@ -0,0 +1,22 @@ +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|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, + ]; } } diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 540b4ac7..d8f28b42 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -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( diff --git a/src/Service/GroupsPriceCalculator.php b/src/Service/GroupsPriceCalculator.php index 23c6951e..82361730 100644 --- a/src/Service/GroupsPriceCalculator.php +++ b/src/Service/GroupsPriceCalculator.php @@ -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 $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 diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index e0a29d56..dc6d748a 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -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. #} +
+

Zeiträume

+

+ 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. +

+ + {% if booking.leadingPeriod %} + {% set p = booking.leadingPeriod %} + {{ block('period_row') }} + {% endif %} + + + + + + + {% if booking.trailingPeriod %} + {% set p = booking.trailingPeriod %} + {{ block('period_row') }} + {% endif %} +
{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}Gruppe + {# One period per side, so only the free sides are offered. #} + {% if booking.leadingPeriod is null or booking.trailingPeriod is null %} + + {% if booking.leadingPeriod is null %} + + am Anfang abteilen + + {% endif %} + {% if booking.trailingPeriod is null %} + + am Ende abteilen + + {% endif %} + + {% endif %} +
+
+ {% if form.boardService is defined or form.selectedAdditionalServices is defined %}

Leistungen

{% 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 %} + + {{ p.dateFrom | date('d.m.Y') }} – {{ p.dateTo | date('d.m.Y') }} + {{ p.nights }} {{ p.nights != 1 ? 'Nächte' : 'Nacht' }} + + {% if p.label %} +
{{ p.label }}
+ {% endif %} + {% for line in p.lines %} +
{{ line.label }}
+ {% else %} +
keine Positionen
+ {% endfor %} + + + {{ (p.total / 100) | format_currency(booking.pricingCurrency ?? booking.accommodation.currency) }} + + + + + bearbeiten + + + entfernen + + + + +{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_period.html.twig b/templates/admin/accommodation_booking/modal_period.html.twig new file mode 100644 index 00000000..45d20e29 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_period.html.twig @@ -0,0 +1,57 @@ +{% extends 'htmx_modal_admin.html.twig' %} +{% form_theme form 'forms_admin.html.twig' %} + +{% macro line_row(line) %} +
+
{{ form_row(line.label) }}
+
{{ form_row(line.amount) }}
+ +
+{% endmacro %} + +{% block content %} + {% import _self as period %} +
+ {{ title }} +
+

+ 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. +

+ {{ form_start(form) }} + {{ form_errors(form) }} +
+ {{ form_row(form.label) }} +
+
+ {{ form_row(form.nights) }} +
+ {% set prototype %}{{ period.line_row(form.lines.vars.prototype) }}{% endset %} +
+
Positionen
+ {{ form_errors(form.lines) }} +
+ {% for line in form.lines %} + {{ period.line_row(line) }} + {% endfor %} +
+ +
+ {% do form.lines.setRendered %} +
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_remove_period.html.twig b/templates/admin/accommodation_booking/modal_remove_period.html.twig new file mode 100644 index 00000000..632b945e --- /dev/null +++ b/templates/admin/accommodation_booking/modal_remove_period.html.twig @@ -0,0 +1,13 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Zeitraum entfernen{% endblock %} + +{% block content %} +
+ 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. +
+{% endblock %} + +{% block button_confirm %}entfernen{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index b45218d5..e90500cb 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -43,6 +43,13 @@
{{ booking.dateTo | date('d.m.Y') }}
Nächte
{{ booking.nights }}
+ {% if booking.periods is not empty %} +
Zeitraum Gruppe
+
+ {{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}, + {{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }} +
+ {% endif %}
Anzahl Personen
{{ booking.paxCount }}
{% if booking.minorsCount > 0 %} @@ -89,6 +96,17 @@

Preise

{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} + {% for period in priceBreakdown.periods | filter(p => p.position == 'leading') %} + {{ block('period_rows') }} + {% endfor %} + {% if priceBreakdown.periods is not empty %} + + + + {% endif %} {% if priceBreakdown.basePrice > 0 %} {% endif %} + {% for period in priceBreakdown.periods | filter(p => p.position == 'trailing') %} + {{ block('period_rows') }} + {% endfor %} + + + {% for line in period.lines %} + + + + + {% endfor %} +{% endblock %} diff --git a/templates/email/_price_breakdown.html.twig b/templates/email/_price_breakdown.html.twig index 750c76cc..aa1d4685 100644 --- a/templates/email/_price_breakdown.html.twig +++ b/templates/email/_price_breakdown.html.twig @@ -11,6 +11,17 @@

Preise

+ Gruppe · {{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}, + {{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }} +
@@ -139,6 +157,9 @@ {{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}
Gesamtpreis @@ -304,3 +325,20 @@ {% endblock %} + +{# One split-off period of the breakdown: its range as a heading, then its lines. #} +{% block period_rows %} +
+ {% 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' }} +
{{ line.label }}{{ (line.amount / 100) | format_currency(currency) }}
+ {% for period in priceBreakdown.periods|filter(p => p.position == 'leading') %} + {% include 'email/_price_breakdown_period.html.twig' %} + {% endfor %} + {% if priceBreakdown.periods is not empty %} + + + + {% endif %} {% if priceBreakdown.basePrice > 0 %} {% endif %} + {% for period in priceBreakdown.periods|filter(p => p.position == 'trailing') %} + {% include 'email/_price_breakdown_period.html.twig' %} + {% endfor %}
+ Gruppe · {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}, + {{ priceBreakdown.nights }} {{ priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }} +
@@ -64,6 +75,9 @@ {{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}
Gesamtpreis @@ -103,3 +117,4 @@ {% endif %}
{% endif %} + diff --git a/templates/email/_price_breakdown_period.html.twig b/templates/email/_price_breakdown_period.html.twig new file mode 100644 index 00000000..550ad726 --- /dev/null +++ b/templates/email/_price_breakdown_period.html.twig @@ -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. -#} + + + {% 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' }} + + +{% for line in period.lines %} + + {{ line.label }} + {{ (line.amount / 100)|format_currency(currency) }} + +{% endfor %} diff --git a/templates/email/_stay_details.html.twig b/templates/email/_stay_details.html.twig index a9d03b1b..e3b77e76 100644 --- a/templates/email/_stay_details.html.twig +++ b/templates/email/_stay_details.html.twig @@ -19,6 +19,12 @@ Nächte {{ booking.nights }} + {% if booking.periods is not empty %} + + Zeitraum Gruppe + {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }} + + {% endif %} Anzahl Personen {{ booking.paxCount }} diff --git a/templates/groups/offer/_summary.html.twig b/templates/groups/offer/_summary.html.twig index cf025b8e..ff205e57 100644 --- a/templates/groups/offer/_summary.html.twig +++ b/templates/groups/offer/_summary.html.twig @@ -67,6 +67,16 @@ {{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }} + {% if ctx.priceBreakdown.periods is not empty %} + + Zeitraum Gruppe + + {{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }}, +
+ {{ ctx.priceBreakdown.nights }} {{ ctx.priceBreakdown.nights != 1 ? 'Nächte' : 'Nacht' }} + + + {% endif %} Anzahl Personen @@ -88,6 +98,14 @@ {% 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 %} + + Gruppe + + {% endif %} {% if ctx.priceBreakdown.basePrice > 0 %} @@ -141,6 +159,9 @@ {{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }} {% endif %} + {% for period in ctx.priceBreakdown.periods|filter(p => p.position == 'trailing') %} + {% include 'groups/offer/_summary_period.html.twig' %} + {% endfor %} Gesamtpreis diff --git a/templates/groups/offer/_summary_period.html.twig b/templates/groups/offer/_summary_period.html.twig new file mode 100644 index 00000000..9fac3a2a --- /dev/null +++ b/templates/groups/offer/_summary_period.html.twig @@ -0,0 +1,15 @@ +{#- One split-off period of the offer summary: its range as a heading, then its lines. + Context: period, currency. -#} + + + {% 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' }} + + +{% for line in period.lines %} + + {{ line.label }} + {{ (line.amount / 100)|format_currency(currency) }} + +{% endfor %} diff --git a/templates/pdf/accommodation_booking.html.twig b/templates/pdf/accommodation_booking.html.twig index 56dfba0c..1e40f86d 100644 --- a/templates/pdf/accommodation_booking.html.twig +++ b/templates/pdf/accommodation_booking.html.twig @@ -83,6 +83,9 @@ Anreise{{ booking.dateFrom|date('d.m.Y') }} Abreise{{ booking.dateTo|date('d.m.Y') }} Nächte{{ booking.nights }} + {% if booking.periods is not empty %} + Zeitraum Gruppe{{ booking.mainDateFrom|date('d.m.Y') }} – {{ booking.mainDateTo|date('d.m.Y') }} + {% endif %} Anzahl Personen{{ booking.paxCount }} {% if booking.minorsCount > 0 %} davon Kinder (0–3 J.){{ booking.minorsCount }} diff --git a/tests/Controller/Admin/AccommodationBooking/EditPeriodControllerTest.php b/tests/Controller/Admin/AccommodationBooking/EditPeriodControllerTest.php new file mode 100644 index 00000000..ec0b2760 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/EditPeriodControllerTest.php @@ -0,0 +1,103 @@ +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 */ + 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 $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 $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Controller/Admin/AccommodationBooking/RemovePeriodControllerTest.php b/tests/Controller/Admin/AccommodationBooking/RemovePeriodControllerTest.php new file mode 100644 index 00000000..28023930 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/RemovePeriodControllerTest.php @@ -0,0 +1,127 @@ +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 */ + public array $flashes = []; + + protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool + { + return $this->tokenValid; + } + + /** + * @param array $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 $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Controller/Admin/AccommodationBooking/SplitPeriodControllerTest.php b/tests/Controller/Admin/AccommodationBooking/SplitPeriodControllerTest.php new file mode 100644 index 00000000..c468c745 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/SplitPeriodControllerTest.php @@ -0,0 +1,180 @@ +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 */ + 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 $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 $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Entity/Groups/AccommodationBookingTest.php b/tests/Entity/Groups/AccommodationBookingTest.php index b9f77e5b..5c9749ef 100644 --- a/tests/Entity/Groups/AccommodationBookingTest.php +++ b/tests/Entity/Groups/AccommodationBookingTest.php @@ -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. diff --git a/tests/Form/Model/AccommodationBookingPeriodDtoTest.php b/tests/Form/Model/AccommodationBookingPeriodDtoTest.php new file mode 100644 index 00000000..40e491ed --- /dev/null +++ b/tests/Form/Model/AccommodationBookingPeriodDtoTest.php @@ -0,0 +1,46 @@ +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()); + } +} diff --git a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php index 358920d7..ee09f625 100644 --- a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php +++ b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php @@ -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 $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 */ diff --git a/tests/Service/AccommodationBookingPdfGeneratorTest.php b/tests/Service/AccommodationBookingPdfGeneratorTest.php index d95e4a7f..0ca6ba4d 100644 --- a/tests/Service/AccommodationBookingPdfGeneratorTest.php +++ b/tests/Service/AccommodationBookingPdfGeneratorTest.php @@ -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]], diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index c9e093c8..fc411c8b 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -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 diff --git a/tests/Service/GroupsPriceCalculatorTest.php b/tests/Service/GroupsPriceCalculatorTest.php index 1ab87806..416add3e 100644 --- a/tests/Service/GroupsPriceCalculatorTest.php +++ b/tests/Service/GroupsPriceCalculatorTest.php @@ -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 $prices + * + * @return array + */ + 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 */ @@ -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, [],