feat: split nights off an accommodation booking as separate periods

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

No files matched your search

@@ -26,7 +26,15 @@ class AccommodationBookingBreakdownCalculator
{
$breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking);
return null !== $breakdown ? $this->withDiscounts($booking, $breakdown) : null;
if (null === $breakdown) {
return null;
}
// Snapshots frozen before split-off periods existed (pricing version 1) read as a
// booking without any, so templates only ever handle one shape.
$breakdown += ['periods' => [], 'periodsTotal' => 0];
return $this->withDiscounts($booking, $breakdown);
}
/**
@@ -100,6 +108,10 @@ class AccommodationBookingBreakdownCalculator
* Calculates against the current catalog. Use only while creating or explicitly editing
* a booking, or as a compatibility fallback for records created before price snapshots.
*
* The catalog prices the main period only; the split-off periods add their lines on top,
* so `total` is the grand total the discounts are taken from. The price rows are loaded for
* the whole stay, which the includedPax floor and the short-term surcharge are judged on.
*
* @return array<string, mixed>|null
*/
public function computeCurrent(AccommodationBooking $booking): ?array
@@ -118,16 +130,40 @@ class AccommodationBookingBreakdownCalculator
$dateTo,
);
return $this->priceCalculator->calculateFromSnapshots(
$breakdown = $this->priceCalculator->calculateFromSnapshots(
$booking->getPaxCount(),
$booking->getMinorsCount(),
$booking->getNights(),
$booking->getMainNights(),
$booking->getMainDateFrom() ?? $dateFrom,
$booking->getMainDateTo() ?? $dateTo,
$dateFrom,
$dateTo,
$booking->getNights(),
$prices,
$booking->getBoardServicePrice(),
$booking->getAdditionalServices(),
$accommodation->getCurrency(),
);
$periods = [];
foreach (array_filter([$booking->getLeadingPeriod(), $booking->getTrailingPeriod()]) as $period) {
$periods[] = [
'position' => $period->getPosition()->value,
'label' => $period->getLabel(),
'dateFrom' => $period->getDateFrom()->format('Y-m-d'),
'dateTo' => $period->getDateTo()->format('Y-m-d'),
'nights' => $period->getNights(),
'lines' => $period->getLines(),
'total' => $period->getTotal(),
];
}
$periodsTotal = $booking->getPeriodsTotal();
return [
...$breakdown,
'periods' => $periods,
'periodsTotal' => $periodsTotal,
'total' => $breakdown['total'] + $periodsTotal,
];
}
}
+57 -1
View File
@@ -8,13 +8,16 @@ use App\Email\EmailAttachmentInterface;
use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -25,11 +28,16 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AccommodationBookingService
{
private const int PRICING_VERSION = 1;
/**
* 2: the breakdown carries the split-off `periods` and `periodsTotal`, and `total` includes
* them. Version 1 snapshots are read as bookings without periods.
*/
private const int PRICING_VERSION = 2;
public function __construct(
private readonly AccommodationRepository $accommodationRepo,
@@ -381,6 +389,54 @@ class AccommodationBookingService
);
}
/**
* The three period changes below each take effect at once, like the house change: they
* reprice the booking, and the office wants to see the new price before it goes on.
* On violations nothing is written and the violations are returned for the modal.
*
* @throws ImplausibleBookingPriceException if the new total is too large to be a real price
*/
public function splitOffPeriod(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
{
$booking->splitOffPeriod($position, (int) $dto->nights)
->setLabel($dto->label)
->setLines($dto->linesSnapshot());
return $this->commitPeriodChange($booking);
}
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
public function updatePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period, AccommodationBookingPeriodDto $dto): ConstraintViolationListInterface
{
$booking->resizePeriod($period, (int) $dto->nights);
$period->setLabel($dto->label)->setLines($dto->linesSnapshot());
return $this->commitPeriodChange($booking);
}
/** @throws ImplausibleBookingPriceException if the new total is too large to be a real price */
public function removePeriod(AccommodationBooking $booking, AccommodationBookingPeriod $period): ConstraintViolationListInterface
{
$booking->removePeriod($period);
return $this->commitPeriodChange($booking);
}
private function commitPeriodChange(AccommodationBooking $booking): ConstraintViolationListInterface
{
// Default only: the `edit` group asks for contact data, which a period change neither
// shows nor touches, and which the edit form already enforces where it belongs.
$violations = $this->validator->validate($booking, null, ['Default']);
if (count($violations) > 0) {
return $violations;
}
$this->refreshPriceSnapshot($booking);
$this->entityManager->flush();
return $violations;
}
public function sendNotificationEmail(AccommodationBooking $booking): void
{
$this->sendBookingEmail(
+21 -6
View File
@@ -86,6 +86,8 @@ class GroupsPriceCalculator
$nights,
$dateFrom,
$dateTo,
$dateFrom,
$nights,
$prices,
$boardService?->getPrice(),
array_map(
@@ -104,7 +106,12 @@ class GroupsPriceCalculator
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
* instead of live catalog entities.
*
* @param AccommodationPrice[] $prices all prices overlapping the booking window
* $dateFrom/$dateTo/$nights are the nights being priced, which is the main period of a booking
* with split-off periods. $stayDateFrom and $stayNights describe the whole stay, which the
* includedPax floor and the short-term surcharge are judged on, so that splitting nights off
* never changes either.
*
* @param AccommodationPrice[] $prices all prices overlapping the whole stay
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
*
* @return array{
@@ -131,6 +138,8 @@ class GroupsPriceCalculator
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
\DateTimeImmutable $stayDateFrom,
int $stayNights,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalServiceSnapshots,
@@ -151,6 +160,8 @@ class GroupsPriceCalculator
$nights,
$dateFrom,
$dateTo,
$stayDateFrom,
$stayNights,
$prices,
$boardServicePricePerPersonNight,
$items,
@@ -186,15 +197,19 @@ class GroupsPriceCalculator
int $nights,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
\DateTimeImmutable $stayDateFrom,
int $stayNights,
array $prices,
?int $boardServicePricePerPersonNight,
array $additionalItems,
string $currency,
): array {
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax.
// The floor comes from the stay's first night, not the priced range's, so that splitting
// nights off the start cannot swap in another price row's floor.
$firstCandidates = array_values(array_filter(
$prices,
fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
fn (AccommodationPrice $p) => $p->getDateFrom() <= $stayDateFrom && $p->getDateTo() >= $stayDateFrom,
));
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
@@ -239,10 +254,10 @@ class GroupsPriceCalculator
}
}
// Rule 3: short-term surcharge
// Rule 3: short-term surcharge, judged on the whole stay but charged on the priced nights
$shortTermSurcharge = 0;
if (isset(self::SHORT_TERM_FACTORS[$nights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100);
if (isset(self::SHORT_TERM_FACTORS[$stayNights])) {
$shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$stayNights] / 100);
}
// Board is only billable — and only triggers the catering surcharge — when it costs