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

@@ -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.