feat: refactored accommodation booking status model

This commit is contained in:
Björn Fromme
2026-08-19 11:46:52 +02:00
parent e8accbb3ed
commit 5a3957e143
42 changed files with 1190 additions and 277 deletions
@@ -9,8 +9,9 @@ use App\Email\PdfAttachment;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
@@ -69,14 +70,14 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Booking);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$service->issueAccessLinkForDirectBooking($booking);
self::assertNotNull($booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void
public function testIssueAccessLinkForDirectBookingNoOpsForAnOffer(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
@@ -84,7 +85,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Inquiry);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$service->issueAccessLinkForDirectBooking($booking);
@@ -99,7 +100,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Booking);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($issuedAt);
@@ -267,7 +268,236 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
public function testAnInquiryIsPersistedAsRequestedSoTheOfficeStillOwesAnOffer(): void
{
// Angefragt, not Offen: the customer has asked, nobody has offered anything yet.
$booking = $this->persistDto(forceInquiry: true);
self::assertSame(AccommodationBookingStatus::Requested, $booking->getStatus());
self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin());
self::assertNull($booking->getAcceptedAt(), 'asking is not committing');
}
public function testADirectBookingIsPersistedAsReceivedAwaitingTheOffice(): void
{
$booking = $this->persistDto(forceInquiry: false);
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingOrigin::Direct, $booking->getOrigin());
self::assertNotNull($booking->getAcceptedAt(), 'booking directly is the commitment');
}
private function persistDto(bool $forceInquiry): AccommodationBooking
{
// A price covering the whole window with no minimum the booking misses, so
// computeInquiryStatus() has no reason of its own to force an inquiry.
$price = (new AccommodationPrice())
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
->setDateTo(new \DateTimeImmutable('2026-09-30'))
->setIncludedPax(10)
->setMinNights(2);
$dto = new AccommodationBookingDto();
$dto->dateFrom = new \DateTimeImmutable('2026-08-01');
$dto->dateTo = new \DateTimeImmutable('2026-08-05');
$dto->paxCount = 40;
$dto->groupName = 'Schulklasse 7b';
$dto->email = '[email protected]';
$dto->forceInquiry = $forceInquiry;
$accommodation = (new Accommodation())->setName('Hotel')->setCalendarCode('HOTEL')->setMaxAdolescentAge(17);
return $this->createServiceWithAccommodation()->persist(
$dto,
$accommodation,
[$price],
['additionalServices' => [], 'boardServices' => []],
);
}
public function testSendOfferIssuesTheLinkMovesToOpenAndMailsTheCustomer(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::atLeastOnce())->method('flush');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setEmail('[email protected]');
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(static fn (array $options) => '[email protected]' === $options['to']
&& 'Dein Angebot ist bereit' === $options['subject']
&& 'email/accommodation_booking_customer.html.twig' === $options['template']),
);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->sendOffer($booking);
self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus());
self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the mail links to the offer, so the link has to exist by the time it goes out');
}
public function testSendOfferPublishesAnAdminAuthoredDraftTheSameWay(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Draft);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus());
}
public function testSendOfferKeepsAnAlreadyIssuedLinkSoEarlierLinksStayValid(): void
{
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setEmail('[email protected]');
$booking->setAccessLinkIssuedAt($issuedAt);
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt());
}
/**
* @dataProvider statusesThatAreNotAwaitingAnOffer
*/
public function testSendOfferNoOpsOnceTheOfferIsOut(AccommodationBookingStatus $status): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->sendOffer($booking);
self::assertSame($status, $booking->getStatus());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesThatAreNotAwaitingAnOffer(): iterable
{
yield 'open' => [AccommodationBookingStatus::Open];
yield 'received' => [AccommodationBookingStatus::Received];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testDiscardBookingClosesTheBookingSilently(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->discardBooking($booking);
self::assertSame(AccommodationBookingStatus::Discarded, $booking->getStatus());
}
/**
* @dataProvider closedStatuses
*/
public function testDiscardBookingNoOpsForAClosedBooking(AccommodationBookingStatus $status): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$booking = new AccommodationBooking();
$booking->setStatus($status);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$service->discardBooking($booking);
self::assertSame($status, $booking->getStatus());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function closedStatuses(): iterable
{
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
/**
* The dead end this flow was rebuilt to remove: Offen used to mean both "inquiry not yet
* offered" and "offer out with the customer", and acceptBooking() additionally demanded
* the Anfrage type. A direct booking nudged into Offen therefore rendered an offer page
* whose accept button silently did nothing, forever. Offen is now reachable only through
* sendOffer(), and acceptance turns on the status alone.
*/
public function testAnOpenBookingCanAlwaysBeAcceptedWhateverItsOrigin(): void
{
foreach (AccommodationBookingOrigin::cases() as $origin) {
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setOrigin($origin);
$booking->setEmail('[email protected]');
$this->createServiceWithAccommodation()->acceptBooking($booking);
self::assertSame(
AccommodationBookingStatus::Received,
$booking->getStatus(),
sprintf('an offer that is out must be acceptable, %s origin included', $origin->value),
);
}
}
public function testTheOriginSurvivesTheWholeLifecycleUntouched(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
$service->acceptBooking($booking);
$service->confirmBooking($booking);
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertSame(
AccommodationBookingOrigin::Offer,
$booking->getOrigin(),
'where a booking came from stays true at every point of its life',
);
}
public function testAcceptBookingMovesOpenOfferToReceivedAndNotifiesTheOfficeOnly(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
@@ -293,7 +523,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->acceptBooking($booking);
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin(), 'accepting does not rewrite where the booking came from');
self::assertNotNull($booking->getAcceptedAt());
}
@@ -482,7 +712,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Inquiry);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$booking->setStatus(AccommodationBookingStatus::Draft);
$service->acceptBooking($booking);