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