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
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\EditPeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/** The modal's error handling is shared with SplitPeriodController and covered there. */
|
||||
class EditPeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testAValidSubmitUpdatesThePeriodOfItsOwnBooking(): void
|
||||
{
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('updatePeriod')
|
||||
->with($booking, $period)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn(true);
|
||||
$form->method('isValid')->willReturn(true);
|
||||
|
||||
$controller = new TestableEditPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||
|
||||
$response = $controller->index($period, Request::create('/admin/accommodation-booking-period/1/edit', 'POST', server: ['HTTP_HX-Request' => 'true']));
|
||||
|
||||
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||
{
|
||||
$controller = new TestableEditPeriodController(
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
$this->createStub(FormInterface::class),
|
||||
);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->index(
|
||||
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||
Request::create('/admin/accommodation-booking-period/1/edit'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableEditPeriodController extends EditPeriodController
|
||||
{
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingService $bookingService,
|
||||
LoggerInterface $logger,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($bookingService, $logger);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\RemovePeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
class RemovePeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testGetAsksForConfirmation(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('removePeriod');
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$controller->index($this->period(), $this->request());
|
||||
|
||||
self::assertSame('admin/accommodation_booking/modal_remove_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testConfirmingRemovesThePeriodAndReturnsToTheEditForm(): void
|
||||
{
|
||||
$period = $this->period();
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('removePeriod')
|
||||
->with($period->getBooking(), $period)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($period, $this->request('POST'));
|
||||
|
||||
self::assertStringStartsWith('/app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAnInvalidTokenRemovesNothing(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('removePeriod');
|
||||
|
||||
$controller = new TestableRemovePeriodController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
$controller->tokenValid = false;
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
|
||||
$controller->index($this->period(), $this->request('POST'));
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutABookingIsNotFound(): void
|
||||
{
|
||||
$controller = new TestableRemovePeriodController($this->createStub(AccommodationBookingService::class), $this->createStub(LoggerInterface::class));
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->index(
|
||||
new AccommodationBookingPeriod(AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-02')),
|
||||
$this->request(),
|
||||
);
|
||||
}
|
||||
|
||||
private function period(): AccommodationBookingPeriod
|
||||
{
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
|
||||
return $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
}
|
||||
|
||||
private function request(string $method = 'GET'): Request
|
||||
{
|
||||
return Request::create('/admin/accommodation-booking-period/1/remove', $method, server: ['HTTP_HX-Request' => 'true']);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableRemovePeriodController extends RemovePeriodController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
public bool $tokenValid = true;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||
{
|
||||
return $this->tokenValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\SplitPeriodController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Exception\ImplausibleBookingPriceException;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Covers how the modal answers — the split itself is tested in AccommodationBookingServiceTest
|
||||
* and AccommodationBookingTest.
|
||||
*/
|
||||
class SplitPeriodControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersTheModal(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: false));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testASideThatWasAlreadySplitIsNotSplitAgain(): void
|
||||
{
|
||||
// The edit page only offers free sides; this is a page left open while the side was taken.
|
||||
$booking = (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-07-08'));
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('splitOffPeriod');
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||
|
||||
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
self::assertNull($controller->renderedView);
|
||||
self::assertSame('error', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAValidSplitSendsTheBrowserBackToTheEditForm(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('splitOffPeriod')
|
||||
->with($booking, AccommodationBookingPeriodPosition::Trailing)
|
||||
->willReturn(new ConstraintViolationList());
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true));
|
||||
|
||||
$response = $controller->index($booking, AccommodationBookingPeriodPosition::Trailing, $this->request('POST'));
|
||||
|
||||
self::assertSame(
|
||||
'/app_admin_accommodationbooking_edit?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||
$response->headers->get('HX-Redirect'),
|
||||
);
|
||||
self::assertSame('success', $controller->flashes[0]['type']);
|
||||
}
|
||||
|
||||
public function testAViolationOfTheBookingIsReportedInTheModal(): void
|
||||
{
|
||||
$violations = new ConstraintViolationList([
|
||||
new ConstraintViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.', null, [], null, 'dateTo', null),
|
||||
]);
|
||||
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||
$bookingService->method('splitOffPeriod')->willReturn($violations);
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertFalse($response->headers->has('HX-Redirect'), 'the modal stays open on the error');
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
self::assertSame([], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testAnImplausibleTotalIsReportedInTheModal(): void
|
||||
{
|
||||
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||
$bookingService->method('splitOffPeriod')
|
||||
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
|
||||
|
||||
$controller = $this->buildController($bookingService, $this->form(submitted: true, expectsError: true));
|
||||
|
||||
$response = $controller->index(new AccommodationBooking(), AccommodationBookingPeriodPosition::Leading, $this->request('POST'));
|
||||
|
||||
self::assertFalse($response->headers->has('HX-Redirect'));
|
||||
self::assertSame('admin/accommodation_booking/modal_period.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
private function request(string $method = 'GET'): Request
|
||||
{
|
||||
return Request::create(
|
||||
'/admin/accommodation-booking/1/period/split/leading?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
|
||||
$method,
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
}
|
||||
|
||||
private function form(bool $submitted, bool $expectsError = false): FormInterface
|
||||
{
|
||||
$form = $expectsError ? $this->createMock(FormInterface::class) : $this->createStub(FormInterface::class);
|
||||
if ($form instanceof \PHPUnit\Framework\MockObject\MockObject) {
|
||||
$form->expects(self::once())->method('addError');
|
||||
}
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn($submitted);
|
||||
$form->method('isValid')->willReturn(true);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
private function buildController(AccommodationBookingService $bookingService, FormInterface $form): TestableSplitPeriodController
|
||||
{
|
||||
return new TestableSplitPeriodController($bookingService, $this->createStub(LoggerInterface::class), $form);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableSplitPeriodController extends SplitPeriodController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingService $bookingService,
|
||||
LoggerInterface $logger,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($bookingService, $logger);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Entity\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Exception\ImplausibleBookingPriceException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class AccommodationBookingTest extends TestCase
|
||||
{
|
||||
@@ -209,6 +213,197 @@ class AccommodationBookingTest extends TestCase
|
||||
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
||||
}
|
||||
|
||||
public function testACopyCarriesItsOwnSplitOffPeriods(): void
|
||||
{
|
||||
// The periods are part of the quote being varied, but editing them on the copy must
|
||||
// not reach back into the original.
|
||||
$booking = $this->confirmedBooking();
|
||||
$booking->addPeriod((new AccommodationBookingPeriod(
|
||||
AccommodationBookingPeriodPosition::Leading,
|
||||
new \DateTimeImmutable('2026-03-01'),
|
||||
new \DateTimeImmutable('2026-03-02'),
|
||||
))->setLines([['label' => 'Vorausteam', 'amount' => 30000]]));
|
||||
$original = $booking->getPeriods()->first();
|
||||
|
||||
$copy = clone $booking;
|
||||
$copied = $copy->getPeriods()->first();
|
||||
|
||||
self::assertCount(1, $copy->getPeriods());
|
||||
self::assertNotSame($original, $copied);
|
||||
self::assertSame($copy, $copied->getBooking());
|
||||
self::assertNull($copied->getId());
|
||||
self::assertSame($original->getLines(), $copied->getLines());
|
||||
self::assertSame($booking, $original->getBooking());
|
||||
}
|
||||
|
||||
public function testWithoutPeriodsTheMainPeriodIsTheWholeStay(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-01'), $booking->getMainDateFrom());
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-08'), $booking->getMainDateTo());
|
||||
self::assertSame(7, $booking->getMainNights());
|
||||
self::assertSame(0, $booking->getPeriodsTotal());
|
||||
}
|
||||
|
||||
public function testTheMainPeriodIsWhatRemainsBetweenTheSplitOffPeriods(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 2000));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 3000));
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-03'), $booking->getMainDateFrom());
|
||||
self::assertEquals(new \DateTimeImmutable('2026-03-07'), $booking->getMainDateTo());
|
||||
self::assertSame(4, $booking->getMainNights());
|
||||
self::assertSame(7, $booking->getNights(), 'the stay itself is unchanged');
|
||||
self::assertSame(5000, $booking->getPeriodsTotal());
|
||||
}
|
||||
|
||||
public function testPeriodsThatTileTheEdgesOfTheStayAreValid(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||
|
||||
self::assertCount(0, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testMovingTheArrivalAwayFromALeadingPeriodIsReportedOnTheArrival(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-02', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateFrom', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testASecondPeriodOnTheSameSideIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testMovingTheDepartureAwayFromATrailingPeriodIsReportedOnTheDeparture(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-09');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testTheGroupMustKeepAtLeastOneNight(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-03');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-02', '2026-03-03', 0));
|
||||
|
||||
$violations = $this->validate($booking);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testAPeriodWithoutANightIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-01', 0));
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testSplittingOffAttachesThePeriodToItsEdgeOfTheStay(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
|
||||
$first = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||
$last = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
self::assertSame(['2026-03-01', '2026-03-03'], $this->range($first));
|
||||
self::assertSame(['2026-03-07', '2026-03-08'], $this->range($last));
|
||||
self::assertSame(4, $booking->getMainNights());
|
||||
self::assertCount(0, $this->validate($booking));
|
||||
}
|
||||
|
||||
public function testEachSideCanOnlyBeSplitOnce(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
self::assertFalse($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Trailing));
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
}
|
||||
|
||||
public function testResizingKeepsAPeriodAtItsEdge(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-10');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
||||
|
||||
$booking->resizePeriod($period, 3);
|
||||
|
||||
self::assertSame(['2026-03-07', '2026-03-10'], $this->range($period));
|
||||
self::assertSame(6, $booking->getMainNights());
|
||||
}
|
||||
|
||||
public function testRemovingAPeriodGivesItsNightsBackAndFreesTheSide(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-08');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
||||
|
||||
$booking->removePeriod($period);
|
||||
|
||||
self::assertNull($period->getBooking());
|
||||
self::assertSame(7, $booking->getMainNights());
|
||||
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
||||
}
|
||||
|
||||
public function testGrowingAPeriodIntoTheLastNightOfTheGroupIsRefused(): void
|
||||
{
|
||||
$booking = $this->stay('2026-03-01', '2026-03-04');
|
||||
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
||||
|
||||
$booking->resizePeriod($period, 3);
|
||||
|
||||
self::assertCount(1, $this->validate($booking));
|
||||
}
|
||||
|
||||
/** @return array{string, string} */
|
||||
private function range(AccommodationBookingPeriod $period): array
|
||||
{
|
||||
return [$period->getDateFrom()->format('Y-m-d'), $period->getDateTo()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
private function stay(string $from, string $to): AccommodationBooking
|
||||
{
|
||||
return (new AccommodationBooking())
|
||||
->setDateFrom(new \DateTimeImmutable($from))
|
||||
->setDateTo(new \DateTimeImmutable($to));
|
||||
}
|
||||
|
||||
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, int $amount): AccommodationBookingPeriod
|
||||
{
|
||||
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
||||
->setLines([['label' => 'Vorausteam', 'amount' => $amount]]);
|
||||
}
|
||||
|
||||
private function validate(AccommodationBooking $booking): ConstraintViolationListInterface
|
||||
{
|
||||
return Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
->validate($booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* A record that has been all the way through the lifecycle, so a copy of it has the most
|
||||
* to reset.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Model;
|
||||
|
||||
use App\Entity\Groups\AccommodationBookingPeriod;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Form\Model\AccommodationBookingPeriodDto;
|
||||
use App\Form\Model\AccommodationBookingPeriodLineDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationBookingPeriodDtoTest extends TestCase
|
||||
{
|
||||
public function testTheEditModalStartsFromWhatThePeriodHolds(): void
|
||||
{
|
||||
$period = (new AccommodationBookingPeriod(
|
||||
AccommodationBookingPeriodPosition::Leading,
|
||||
new \DateTimeImmutable('2026-07-01'),
|
||||
new \DateTimeImmutable('2026-07-03'),
|
||||
))
|
||||
->setLabel('Vorausteam')
|
||||
->setLines([['label' => 'Übernachtung', 'amount' => 25000]]);
|
||||
|
||||
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
|
||||
|
||||
self::assertSame('Vorausteam', $dto->label);
|
||||
self::assertSame(2, $dto->nights);
|
||||
self::assertSame([['label' => 'Übernachtung', 'amount' => 25000]], $dto->linesSnapshot());
|
||||
}
|
||||
|
||||
public function testTheLinesAreStoredAsAListWhateverKeysTheFormSubmitted(): void
|
||||
{
|
||||
// A collection form keeps the indexes of the rows it was sent, so after a removed row
|
||||
// the keys have gaps — the JSON column must still get a list, not an object.
|
||||
$dto = new AccommodationBookingPeriodDto(1, [
|
||||
2 => new AccommodationBookingPeriodLineDto('Übernachtung', 25000),
|
||||
5 => new AccommodationBookingPeriodLineDto('Verpflegung', 6000),
|
||||
]);
|
||||
|
||||
self::assertSame([
|
||||
['label' => 'Übernachtung', 'amount' => 25000],
|
||||
['label' => 'Verpflegung', 'amount' => 6000],
|
||||
], $dto->linesSnapshot());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
|
||||
Reference in new issue
Block a user