344 lines
14 KiB
PHP
344 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
use App\Service\PriceTimelineBuilder;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
|
{
|
|
public function testComputeReturnsStoredSnapshotWithoutLoadingCurrentPrices(): void
|
|
{
|
|
$snapshot = ['total' => 12345, 'currency' => 'EUR'];
|
|
$booking = new AccommodationBooking();
|
|
$booking->setPriceSnapshot($snapshot, 12345, 'EUR', 1);
|
|
|
|
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
|
$priceRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
|
|
|
$breakdown = $this->createCalculator($priceRepository)->compute($booking);
|
|
|
|
self::assertSame(12345, $breakdown['total']);
|
|
self::assertSame('EUR', $breakdown['currency']);
|
|
}
|
|
|
|
public function testComputeEnrichesTheStoredSnapshotWithDiscountRowsAndTotal(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1);
|
|
$booking->setAccommodationDiscount(10);
|
|
|
|
$breakdown = $this->createCalculator()->compute($booking);
|
|
|
|
self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']);
|
|
self::assertSame(12345 - 1000, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
public function testWithDiscountsListsOneRowPerConfiguredDiscount(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setAccommodationDiscount(10);
|
|
$booking->setBoardServiceDiscount(20);
|
|
$booking->setAdditionalServicesDiscount(50);
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown());
|
|
|
|
// accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250
|
|
self::assertSame([
|
|
['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000],
|
|
['label' => 'Rabatt Verpflegung', 'percent' => 20, 'amount' => 200],
|
|
['label' => 'Rabatt Zusatzleistungen', 'percent' => 50, 'amount' => 250],
|
|
], $breakdown['discounts']);
|
|
self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
public function testWithDiscountsRoundsToWholeCents(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setBoardServiceDiscount(15);
|
|
|
|
// 333 * 15% = 49.95 — the amount has to land on whole cents, and the discounted total
|
|
// has to be reduced by the very number that is shown on the row.
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 12345, 'boardPrice' => 333]);
|
|
|
|
self::assertSame(50, $breakdown['discounts'][0]['amount']);
|
|
self::assertSame(12345 - 50, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
public function testWithDiscountsSkipsDiscountsThatAmountToNothing(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setAccommodationDiscount(0);
|
|
// A discount on a bucket this booking has no price in.
|
|
$booking->setAdditionalServicesDiscount(20);
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, [
|
|
'total' => 12345,
|
|
'basePrice' => 8000,
|
|
'servicesPrice' => 0,
|
|
]);
|
|
|
|
self::assertSame([], $breakdown['discounts']);
|
|
self::assertSame(12345, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
public function testWithoutDiscountsTheDiscountedTotalIsTheTotal(): void
|
|
{
|
|
$breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown());
|
|
|
|
self::assertSame([], $breakdown['discounts']);
|
|
self::assertNull($breakdown['totalDiscountDetails']);
|
|
self::assertNull($breakdown['discountSubtotal']);
|
|
self::assertSame(12345, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
/**
|
|
* On its own the total discount has nothing to sit below, so no subtotal row is offered and
|
|
* the amount comes straight off the plain total.
|
|
*/
|
|
public function testTotalDiscountAloneAppliesToTheTotalWithoutASubtotal(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setTotalDiscountAmount(1235);
|
|
$booking->setTotalDiscountLabel('Treuerabatt');
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown());
|
|
|
|
self::assertSame([], $breakdown['discounts']);
|
|
self::assertSame(['label' => 'Treuerabatt', 'amount' => 1235], $breakdown['totalDiscountDetails']);
|
|
self::assertNull($breakdown['discountSubtotal']);
|
|
self::assertSame(12345 - 1235, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
/**
|
|
* The worked example from the specification: a fixed 50,00 € off the 930,00 € left by the
|
|
* section discounts, which is where the Zwischensumme row comes from.
|
|
*/
|
|
public function testTotalDiscountIsSubtractedFromTheSubtotalAfterSectionDiscounts(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setAccommodationDiscount(10);
|
|
$booking->setBoardServiceDiscount(5);
|
|
$booking->setTotalDiscountAmount(5000);
|
|
$booking->setTotalDiscountLabel('Treuerabatt');
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, [
|
|
'total' => 100000,
|
|
'basePrice' => 60000,
|
|
'boardPrice' => 20000,
|
|
]);
|
|
|
|
// accommodation: 60000*10% = 6000, board: 20000*5% = 1000 → subtotal 93000
|
|
self::assertSame(93000, $breakdown['discountSubtotal']);
|
|
self::assertSame(['label' => 'Treuerabatt', 'amount' => 5000], $breakdown['totalDiscountDetails']);
|
|
self::assertSame(88000, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
/**
|
|
* A sum larger than the price is clamped rather than refused: prices move after the discount
|
|
* was agreed, and a negative total would be worse than a free stay.
|
|
*/
|
|
public function testTotalDiscountLargerThanTheSubtotalIsClampedToIt(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setTotalDiscountAmount(20000);
|
|
$booking->setTotalDiscountLabel('Treuerabatt');
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown());
|
|
|
|
self::assertSame(['label' => 'Treuerabatt', 'amount' => 12345], $breakdown['totalDiscountDetails']);
|
|
self::assertSame(0, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
public function testTotalDiscountThatAmountsToNothingIsSkipped(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setTotalDiscountAmount(2000);
|
|
$booking->setTotalDiscountLabel('Treuerabatt');
|
|
|
|
$breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 0]);
|
|
|
|
self::assertNull($breakdown['totalDiscountDetails']);
|
|
self::assertNull($breakdown['discountSubtotal']);
|
|
self::assertSame(0, $breakdown['discountedTotal']);
|
|
}
|
|
|
|
/**
|
|
* The derived keys must never reach the database: refreshPriceSnapshot() freezes exactly
|
|
* what computeCurrent() returns.
|
|
*/
|
|
public function testComputeCurrentReturnsTheRawBreakdown(): void
|
|
{
|
|
$booking = new AccommodationBooking();
|
|
$booking->setAccommodationDiscount(10);
|
|
$booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('CHF'));
|
|
$booking->setDateFrom(new \DateTimeImmutable('2026-08-01'));
|
|
$booking->setDateTo(new \DateTimeImmutable('2026-08-03'));
|
|
|
|
$breakdown = $this->createCalculator()->computeCurrent($booking);
|
|
|
|
self::assertArrayNotHasKey('discounts', $breakdown);
|
|
self::assertArrayNotHasKey('discountSubtotal', $breakdown);
|
|
self::assertArrayNotHasKey('totalDiscountDetails', $breakdown);
|
|
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>
|
|
*/
|
|
private function breakdown(): array
|
|
{
|
|
return [
|
|
'total' => 12345,
|
|
'currency' => 'CHF',
|
|
'basePrice' => 8000,
|
|
'additionalPersonsPrice' => 2000,
|
|
'boardPrice' => 1000,
|
|
'servicesPrice' => 500,
|
|
];
|
|
}
|
|
|
|
private function createCalculator(?AccommodationPriceRepository $priceRepository = null): AccommodationBookingBreakdownCalculator
|
|
{
|
|
return new AccommodationBookingBreakdownCalculator(
|
|
new GroupsPriceCalculator(new PriceTimelineBuilder(), [
|
|
'runningCostsEur' => 0,
|
|
'runningCostsChf' => 0,
|
|
'undersubscription30Eur' => 0,
|
|
'undersubscription30Chf' => 0,
|
|
'undersubscription40Eur' => 0,
|
|
'undersubscription40Chf' => 0,
|
|
]),
|
|
$priceRepository ?? $this->createStub(AccommodationPriceRepository::class),
|
|
);
|
|
}
|
|
}
|