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

@@ -6,6 +6,9 @@ namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\GroupsPriceCalculator;
@@ -190,6 +193,124 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
self::assertArrayNotHasKey('discountedTotal', $breakdown);
}
public function testAVersion1SnapshotReadsAsABookingWithoutPeriods(): void
{
$booking = new AccommodationBooking();
$booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1);
$breakdown = $this->createCalculator()->compute($booking);
self::assertSame([], $breakdown['periods']);
self::assertSame(0, $breakdown['periodsTotal']);
self::assertSame(12345, $breakdown['discountedTotal']);
}
public function testWithoutPeriodsTheCatalogPricesTheWholeStay(): void
{
// Regression guard: a booking nobody split is priced exactly as before periods existed.
$booking = $this->stay();
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
self::assertSame(7, $breakdown['nights']);
self::assertSame(7 * 100_00, $breakdown['basePrice']);
self::assertSame(7 * 100_00, $breakdown['total']);
self::assertSame([], $breakdown['periods']);
self::assertSame(0, $breakdown['periodsTotal']);
}
public function testTheCatalogPricesOnlyTheMainPeriodAndThePeriodLinesAreAddedOnTop(): void
{
$booking = $this->stay();
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-07-07', '2026-07-08', [['label' => 'Abbau', 'amount' => 100_00]]));
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [
['label' => 'Übernachtung', 'amount' => 250_00],
['label' => 'Verpflegung', 'amount' => 50_00],
])->setLabel('Vorausteam'));
$breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking);
self::assertSame(5, $breakdown['nights']);
self::assertSame(5 * 100_00, $breakdown['basePrice']);
self::assertSame(400_00, $breakdown['periodsTotal']);
self::assertSame(5 * 100_00 + 400_00, $breakdown['total']);
self::assertSame([
[
'position' => 'leading',
'label' => 'Vorausteam',
'dateFrom' => '2026-07-01',
'dateTo' => '2026-07-02',
'nights' => 1,
'lines' => [
['label' => 'Übernachtung', 'amount' => 250_00],
['label' => 'Verpflegung', 'amount' => 50_00],
],
'total' => 300_00,
],
[
'position' => 'trailing',
'label' => null,
'dateFrom' => '2026-07-07',
'dateTo' => '2026-07-08',
'nights' => 1,
'lines' => [['label' => 'Abbau', 'amount' => 100_00]],
'total' => 100_00,
],
], $breakdown['periods']);
}
public function testOnlyTheFixedDiscountReachesThePeriodLines(): void
{
// The section discounts belong to the catalog-priced main period; the fixed discount
// is taken off the grand total, so it may exceed what the main period alone costs.
$booking = $this->stay();
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [['label' => 'Vorausteam', 'amount' => 1000_00]]));
$booking->setAccommodationDiscount(10);
$booking->setTotalDiscountAmount(900_00);
$booking->setTotalDiscountLabel('Sonderpreis');
$calculator = $this->createCalculator($this->priceRepository());
$breakdown = $calculator->withDiscounts($booking, $calculator->computeCurrent($booking));
self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 60_00]], $breakdown['discounts']);
self::assertSame(600_00 + 1000_00 - 60_00, $breakdown['discountSubtotal']);
self::assertSame(600_00 + 1000_00 - 60_00 - 900_00, $breakdown['discountedTotal']);
}
private function stay(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('EUR'));
$booking->setDateFrom(new \DateTimeImmutable('2026-07-01'));
$booking->setDateTo(new \DateTimeImmutable('2026-07-08'));
$booking->setPaxCount(20);
return $booking;
}
/** @param list<array{label: string, amount: int}> $lines */
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, array $lines): AccommodationBookingPeriod
{
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
->setLines($lines);
}
private function priceRepository(): AccommodationPriceRepository
{
$price = (new AccommodationPrice())
->setDateFrom(new \DateTimeImmutable('2026-06-01'))
->setDateTo(new \DateTimeImmutable('2026-08-31'))
->setIncludedPax(20)
->setPricePerNight(100_00)
->setPriceAdditionalPerson(10_00)
->setMinNights(1);
$priceRepository = $this->createStub(AccommodationPriceRepository::class);
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn([$price]);
return $priceRepository;
}
/**
* @return array<string, mixed>
*/
@@ -6,6 +6,7 @@ namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingPdfGenerator;
use PHPUnit\Framework\TestCase;
@@ -27,6 +28,37 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
self::assertSame(1, $this->pageCount($pdf));
}
public function testADocumentWithSplitOffPeriodsStillFitsOnASingleA4Page(): void
{
$booking = $this->booking();
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1)->setLabel('Vorausteam')->setLines([
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
]);
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1)->setLines([
['label' => 'Abbau', 'amount' => 10000],
]);
$breakdown = [
...$this->breakdown(),
'nights' => 3,
'periods' => [
['position' => 'leading', 'label' => 'Vorausteam', 'dateFrom' => '2026-07-06', 'dateTo' => '2026-07-07', 'nights' => 1, 'lines' => [
['label' => 'Vorausteam Übernachtung', 'amount' => 25000],
['label' => 'Vorausteam Verpflegung', 'amount' => 6000],
], 'total' => 31000],
['position' => 'trailing', 'dateFrom' => '2026-07-10', 'dateTo' => '2026-07-11', 'nights' => 1, 'lines' => [
['label' => 'Abbau', 'amount' => 10000],
], 'total' => 10000],
],
'periodsTotal' => 41000,
];
$pdf = $this->createGenerator($breakdown)->render($booking);
self::assertSame(1, $this->pageCount($pdf));
}
public function testTheDownloadIsAnAttachmentNamedAfterArrivalDateAndHotel(): void
{
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
@@ -122,6 +154,8 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
'servicesPrice' => 28800,
'runningCosts' => 16000,
'periods' => [],
'periodsTotal' => 0,
'total' => 844800,
'currency' => 'EUR',
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
@@ -10,10 +10,13 @@ use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
@@ -951,7 +954,86 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame($breakdown, $booking->getPriceBreakdown(), 'the derived keys must not be persisted');
self::assertSame(10895, $booking->getTotalPrice());
self::assertSame('CHF', $booking->getPricingCurrency());
self::assertSame(1, $booking->getPricingVersion());
self::assertSame(2, $booking->getPricingVersion());
}
public function testSplittingOffAPeriodRepricesAndWritesTheBooking(): void
{
$booking = $this->periodBooking();
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::once())->method('computeCurrent')->with($booking)->willReturn(null);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
$violations = $service->splitOffPeriod(
$booking,
AccommodationBookingPeriodPosition::Leading,
new AccommodationBookingPeriodDto(1, [3 => new AccommodationBookingPeriodLineDto('Übernachtung', 30000)], 'Vorausteam'),
);
self::assertCount(0, $violations);
self::assertSame('Vorausteam', $booking->getLeadingPeriod()?->getLabel());
self::assertSame([['label' => 'Übernachtung', 'amount' => 30000]], $booking->getLeadingPeriod()?->getLines());
self::assertSame(6, $booking->getMainNights());
}
public function testASplitThatLeavesTheGroupNoNightIsNeitherPricedNorWritten(): void
{
$booking = $this->periodBooking();
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('computeCurrent');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, breakdownCalculator: $breakdownCalculator);
$violations = $service->splitOffPeriod($booking, AccommodationBookingPeriodPosition::Trailing, new AccommodationBookingPeriodDto(7));
self::assertCount(1, $violations);
}
public function testUpdatingAPeriodChangesItsLengthAndLines(): void
{
$booking = $this->periodBooking();
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$violations = $service->updatePeriod($booking, $period, new AccommodationBookingPeriodDto(2, [new AccommodationBookingPeriodLineDto('Abbau', 5000)]));
self::assertCount(0, $violations);
self::assertSame(2, $period->getNights());
self::assertSame([['label' => 'Abbau', 'amount' => 5000]], $period->getLines());
self::assertSame(5, $booking->getMainNights());
}
public function testRemovingAPeriodGivesItsNightsBackToTheGroup(): void
{
$booking = $this->periodBooking();
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$violations = $service->removePeriod($booking, $period);
self::assertCount(0, $violations);
self::assertTrue($booking->getPeriods()->isEmpty());
self::assertSame(7, $booking->getMainNights());
}
private function periodBooking(): AccommodationBooking
{
return (new AccommodationBooking())
->setAccommodation((new Accommodation())->setCalendarCode('HOTEL'))
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
->setDateTo(new \DateTimeImmutable('2026-07-08'))
->setPaxCount(20);
}
public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void
+94 -10
View File
@@ -48,6 +48,97 @@ class GroupsPriceCalculatorTest extends TestCase
self::assertSame(50, $result['undersubscriptionThreshold']);
}
public function testTheShortTermSurchargeIsJudgedOnTheWholeStay(): void
{
// Three nights priced for the group, but one night split off a four-night stay: the
// stay is not short-term, so the three nights must not pick up the 10 % surcharge.
$price = $this->price('2026-06-01', '2026-08-31', 20);
$split = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-01', 4, [$price]);
$unsplit = $this->calculateMain('2026-07-02', '2026-07-05', '2026-07-02', 3, [$price]);
self::assertSame(0, $split['shortTermSurcharge']);
self::assertSame(3, $split['nights']);
self::assertSame((int) round(($unsplit['basePrice'] + $unsplit['additionalPersonsPrice']) * 10 / 100), $unsplit['shortTermSurcharge']);
}
public function testTheShortTermSurchargeOfAShortStayIsChargedOnThePricedNightsOnly(): void
{
// A three-night stay with one night split off: 10 % on the two nights the group pays
// for, not 20 % for a two-night stay and not on the split-off night's lines.
$price = $this->price('2026-06-01', '2026-08-31', 20);
$result = $this->calculateMain('2026-07-02', '2026-07-04', '2026-07-01', 3, [$price]);
self::assertSame(2 * 100_00, $result['basePrice']);
self::assertSame((int) round(($result['basePrice'] + $result['additionalPersonsPrice']) * 10 / 100), $result['shortTermSurcharge']);
}
public function testTheIncludedPaxFloorComesFromTheStaysFirstNight(): void
{
// The stay starts in a price period with a floor of 30; the main group arrives a day
// later, in one with a floor of 50. Splitting the first night off must not raise the
// floor the 20 people are billed at.
$prices = [
$this->price('2026-06-01', '2026-07-01', 30),
$this->price('2026-07-02', '2026-08-31', 50),
];
$result = $this->calculateMain('2026-07-02', '2026-07-08', '2026-07-01', 7, $prices, paxCount: 20, boardServicePrice: 1500);
self::assertSame(30, $result['includedPax']);
self::assertSame(30, $result['effectivePax']);
self::assertSame(1500 * 30 * 6, $result['boardPrice']);
}
/**
* @param list<AccommodationPrice> $prices
*
* @return array<string, mixed>
*/
private function calculateMain(string $from, string $to, string $stayFrom, int $stayNights, array $prices, int $paxCount = 20, ?int $boardServicePrice = null): array
{
$dateFrom = new \DateTimeImmutable($from);
$dateTo = new \DateTimeImmutable($to);
return $this->calculator()->calculateFromSnapshots(
$paxCount,
0,
$dateFrom->diff($dateTo)->days,
$dateFrom,
$dateTo,
new \DateTimeImmutable($stayFrom),
$stayNights,
$prices,
$boardServicePrice,
[],
'EUR',
);
}
private function price(string $from, string $to, int $includedPax): AccommodationPrice
{
return (new AccommodationPrice())
->setDateFrom(new \DateTimeImmutable($from))
->setDateTo(new \DateTimeImmutable($to))
->setIncludedPax($includedPax)
->setPricePerNight(100_00)
->setPriceAdditionalPerson(10_00)
->setMinNights(1);
}
private function calculator(): GroupsPriceCalculator
{
return new GroupsPriceCalculator(new PriceTimelineBuilder(), [
'runningCostsEur' => 0,
'runningCostsChf' => 0,
'undersubscription30Eur' => 5.0,
'undersubscription30Chf' => 6.0,
'undersubscription40Eur' => 2.5,
'undersubscription40Chf' => 3.0,
]);
}
/**
* @return array<string, mixed>
*/
@@ -64,21 +155,14 @@ class GroupsPriceCalculatorTest extends TestCase
->setPriceAdditionalPerson(10_00)
->setMinNights(1);
$calculator = new GroupsPriceCalculator(new PriceTimelineBuilder(), [
'runningCostsEur' => 0,
'runningCostsChf' => 0,
'undersubscription30Eur' => 5.0,
'undersubscription30Chf' => 6.0,
'undersubscription40Eur' => 2.5,
'undersubscription40Chf' => 3.0,
]);
return $calculator->calculateFromSnapshots(
return $this->calculator()->calculateFromSnapshots(
$paxCount,
0,
self::NIGHTS,
$dateFrom,
$dateTo,
$dateFrom,
self::NIGHTS,
[$price],
$boardServicePrice,
[],