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>
*/