feat: consolidate micro-services and unify draft merge logic

This commit is contained in:
Björn Fromme
2026-04-16 13:34:54 +02:00
parent fdfd5d56c9
commit 0d2cc5b998
16 changed files with 529 additions and 846 deletions
+17 -79
View File
@@ -1,6 +1,6 @@
# Service Simplification Plan
Status: draft
Status: active
Last updated: 2026-04-13
## Purpose
@@ -17,9 +17,10 @@ The emphasis is not on deleting services for its own sake. The emphasis is on:
The codebase is already in a better place than it was at the start of the refactor, but a few services still carry more than one responsibility:
- `BookingService` no longer owns session lifecycle, baseline snapshot handling, return URL management, or room grouping. That work now lives in `BookingSessionService` and `BookingRoomSelectionService`, which keeps the booking orchestration boundary narrower.
- `BookingService` still covers hydration, booking bootstrap, service preselection, and booking status rules.
- `BookingParticipantCountService` now handles only participant count shaping.
- `BookingService` now mostly covers booking bootstrap, service preselection, and booking status rules.
- `BookingParticipantCountService` was removed — its logic lives as a private method in the one controller that needed it (`Step2Controller`).
- `BookingRoomSelectionService` was removed — its logic lives as a private method in `BookingCreateContextFactory`.
- `BookingSummaryParticipantCountService` was removed — its logic lives as a private method in `BookingSummaryDataService`.
- `ParticipantPrepopulationService` now owns applicant prefill plus the create-mode dummy-data shortcut.
- `BookingEditParticipantContextFactory` now prepares the edit participant page context directly, replacing the older pass-through participant form service.
- `BookingPriceCalculatorService` is focused on pricing, but it still sits close to display-oriented behavior in adjacent code paths.
@@ -32,22 +33,7 @@ It is not just a lookup table. It owns execution order, edit-mode mutability gat
## Next Pass
### 1. Reduce `BookingService`
Primary goal: make the booking create/edit flow easier to read by splitting unrelated concerns.
Concrete next steps:
- keep booking session lifecycle in one place
- extract baseline room snapshot handling into a narrower helper or dedicated service
- separate return URL handling if it stays conceptually unrelated
- keep `startFreshBooking()` focused on booking bootstrap rather than general session utilities
- keep hydration behavior obvious and local to the booking session path
Decision rule:
- if a method only forwards to DTO/session behavior, prefer removing the wrapper
- if a method is a genuine workflow owner, keep it and narrow the surrounding API instead of splitting it into generic helpers
### 2. Keep pricing calculation focused
### 1. Keep pricing calculation focused
Primary goal: keep pricing code about pricing, not rendering.
@@ -60,63 +46,7 @@ Concrete next steps:
Decision rule:
- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible
### 3. Leave the field-handler registry in place
Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer.
Concrete next steps:
- do not refactor `ParticipantFieldHandlerRegistry` in this pass
- revisit only if a later change can split ordering, mutability, and synchronization into clear collaborators without making the flow harder to trace
Decision rule:
- if a registry owns actual workflow behavior, treat it as a boundary rather than a smell
## Implementation Backlog
### 1. Keep the summary count contract explicit
Goal: make the booking summary read clearly without duplicating equivalent count fields.
Tasks:
- use a single summary-facing count field for the sidebar and step summary views
- keep the room-capacity-derived meaning explicit in the field name and docblock
- keep the participant-shaping count logic separate if the code still needs it internally
- remove template branching that compares two equivalent summary counts
Acceptance criteria:
- the summary template reads one count field, not two equivalent ones
- the field name makes the room-capacity meaning obvious to a new developer
- participant-shaping logic can still use its own internal count without leaking that distinction into the view layer
### 2. Reduce `BookingService`
Primary goal: make the booking create/edit flow easier to read by splitting unrelated concerns.
Concrete next steps:
- keep booking session lifecycle in one place
- extract baseline room snapshot handling into a narrower helper or dedicated service
- separate return URL handling if it stays conceptually unrelated
- keep `startFreshBooking()` focused on booking bootstrap rather than general session utilities
- keep hydration behavior obvious and local to the booking session path
Decision rule:
- if a method only forwards to DTO/session behavior, prefer removing the wrapper
- if a method is a genuine workflow owner, keep it and narrow the surrounding API instead of splitting it into generic helpers
### 3. Keep pricing calculation focused
Primary goal: keep pricing code about pricing, not rendering.
Concrete next steps:
- keep `BookingPriceCalculatorService` as the pricing boundary
- continue removing display formatting from pricing code paths
- keep any remaining view-specific formatting in the presentation layer or a dedicated UI helper
- avoid introducing another service that only formats values already known to the view
Decision rule:
- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible
### 4. Leave the field-handler registry in place
### 2. Leave the field-handler registry in place
Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer.
@@ -157,13 +87,21 @@ Likely directions, only if justified later:
- split synchronization code if a clearer DTO/form boundary emerges
- otherwise leave it as the central orchestration point for participant field processing
## Current Notes
- `templates/booking/_summary_travel_info.html.twig` already renders the summary participant count directly, so the remaining work here is naming and contract clarity rather than Twig branching.
- The summary count still needs a clear name if the code should distinguish the display-oriented count from the canonical participant total in the DTO/service layer.
- `ParticipantFormSupportService` was reviewed and kept: it is shared between two controllers (`Step2ParticipantController` and `Edit/ParticipantController`) with real shared logic, not just delegation.
## Progress Tracker
| Item | Status | Notes |
|------|--------|-------|
| Participant card DTO cleanup | Done | Card data now uses typed DTOs instead of nested array payloads |
| Room label formatting cleanup | Done | Pricing labels now have a dedicated presentation helper |
| Booking service split | In progress | Session lifecycle, baseline snapshot, return URL handling, room grouping, and participant count shaping moved out of `BookingService`; dummy prefill moved into `ParticipantPrepopulationService` |
| Booking service split | Done | Session lifecycle, hydration, baseline snapshot, return URL handling, room grouping, and participant count shaping moved out of `BookingService` |
| Dummy prefill extraction | Done | Moved into `ParticipantPrepopulationService` |
| Micro-service consolidation | Done | `BookingRoomSelectionService`, `BookingParticipantCountService`, `BookingSummaryParticipantCountService` inlined into their single callers |
| Pricing service review | Pending | Keep focused on calculation, not rendering |
| Travel data service review | Pending | Broad boundary, likely later pass |
| Participant field registry review | Deferred | Real orchestration boundary, intentionally left alone for now |
@@ -174,7 +112,7 @@ The next booking-service pass is only worth keeping if it:
- reduces the number of unrelated responsibilities in `BookingService`
- makes the booking flow easier to trace from controller to session/DTO state
- preserves existing booking behavior and test coverage
- does not replace one large service with several generic manager classes
- does not replace one large service with several generic "manager" classes
## Working Agreement
@@ -4,18 +4,19 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\Model\Travel;
use App\Entity\User;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingService;
use App\Service\BookingParticipantCountService;
use App\Service\BookingSessionService;
use App\Service\ParticipantPrepopulationService;
use App\Service\RoomAssignmentService;
use App\Service\TravelDataService;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionStore;
use App\Service\ParticipantDataPrefiller;
use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -33,13 +34,12 @@ class Step2Controller extends AbstractController
use BookingExceptionHandlerTrait;
public function __construct(
private readonly BookingService $bookingService,
private readonly BookingParticipantCountService $participantCountService,
private readonly BookingSessionService $bookingSessionService,
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionStore $bookingSessionService,
private readonly BookingCreateContextFactory $createContextFactory,
private readonly TravelDataService $travelDataService,
private readonly RoomAssignmentService $roomAssignmentService,
private readonly ParticipantPrepopulationService $prepopulationService,
private readonly TravelDataProvider $travelDataService,
private readonly RoomAssigner $roomAssignmentService,
private readonly ParticipantDataPrefiller $prepopulationService,
) {
}
@@ -65,13 +65,13 @@ class Step2Controller extends AbstractController
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
// Ensure correct number of participants first, then prepopulate the applicant if needed.
$this->participantCountService->ensureCorrectNumberOfParticipants($bookingCreateDto);
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
$user = $this->getUser();
if ($user instanceof User
&& isset($bookingCreateDto->participants[0])
&& $this->prepopulationService->shouldPrepopulateApplicant($bookingCreateDto->participants[0])) {
$bookingCreateDto->participants[0] = $this->prepopulationService->prepopulateApplicantFromUser(
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
$user,
$bookingCreateDto->participants[0]
);
@@ -114,4 +114,34 @@ class Step2Controller extends AbstractController
return $this->render('booking/create/step_2.html.twig', $templateData);
}
/**
* Ensures the booking DTO has the expected number of participant objects based on room selections.
*/
private function ensureCorrectNumberOfParticipants(BookingDto $bookingDto): void
{
$participantsCount = $this->calculateParticipantsCount($bookingDto->roomSelections, $bookingDto->travel);
$existingParticipants = $bookingDto->participants;
$bookingDto->participants = [];
for ($i = 0; $i < $participantsCount; ++$i) {
$participant = $existingParticipants[$i] ?? new ParticipantDto();
$participant->index = $i;
$bookingDto->participants[$i] = $participant;
}
}
private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int
{
$participantsCount = 0;
$rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->id];
$participantsCount += $room->minPax * $roomSelection->quantity;
}
return $participantsCount;
}
}
+33 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Room;
use App\Form\Model\BookingCreateContext;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
@@ -15,10 +16,9 @@ use App\Form\Model\RoomGroupsDto;
class BookingCreateContextFactory
{
public function __construct(
private readonly BookingRoomSelectionService $roomSelectionService,
private readonly ParticipantCardDataService $participantCardDataService,
private readonly BookingSummaryDataService $summaryDataService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ParticipantCardAssembler $participantCardDataService,
private readonly BookingSummaryAssembler $summaryDataService,
private readonly BookingPriceCalculator $priceCalculator,
) {
}
@@ -68,7 +68,35 @@ class BookingCreateContextFactory
{
return [
'summaryData' => $this->summaryDataService->getSummaryData($bookingDto, $pricingMode),
'groupedRooms' => $this->roomSelectionService->groupRoomsBySelectionType($bookingDto->travel->getAvailableRooms()),
'groupedRooms' => $this->groupRoomsBySelectionType($bookingDto->travel->getAvailableRooms()),
];
}
/**
* @param array<int, Room> $rooms
*/
private function groupRoomsBySelectionType(array $rooms): RoomGroupsDto
{
$groups = [
Room::SELECTION_TYPE_BY_PAX => [],
Room::SELECTION_TYPE_BY_ROOM => [],
];
foreach ($rooms as $room) {
if (false !== stripos($room->label, 'bett')) {
$groups[Room::SELECTION_TYPE_BY_PAX][$room->id] = $room;
continue;
}
$groups[Room::SELECTION_TYPE_BY_ROOM][$room->id] = $room;
}
uasort($groups[Room::SELECTION_TYPE_BY_PAX], static fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax);
uasort($groups[Room::SELECTION_TYPE_BY_ROOM], static fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax);
return new RoomGroupsDto(
byPax: $groups[Room::SELECTION_TYPE_BY_PAX],
byRoom: $groups[Room::SELECTION_TYPE_BY_ROOM],
);
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Travel;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Repository\BookingEditDraftRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Manages draft persistence for the booking edit flow.
*
* This service handles saving, loading, and applying draft data to prevent
* data loss when BusProNet API rejects booking update submissions. Drafts
* are automatically applied on top of fresh API data when a user returns
* to edit a booking.
*/
class BookingEditDraftManager
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly BookingChangeTracker $fingerprintService,
private readonly BookingEditDraftMerger $participantApplier,
private readonly LoggerInterface $logger,
) {
}
/**
* Finds an existing draft for a user and booking combination.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
*
* @return BookingEditDraft|null The draft if found, null otherwise
*/
public function findDraft(User $user, int $bookingId): ?BookingEditDraft
{
return $this->draftRepository->findByUserAndBooking($user, $bookingId);
}
/**
* Saves or updates a draft for the given booking.
*
* Uses an upsert pattern: creates a new draft if none exists,
* or updates the existing draft with new form data.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
* @param BookingDto $bookingDto The booking DTO containing user edits
*/
public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void
{
$formData = $this->fingerprintService->extractUserData($bookingDto);
$travelDate = $bookingDto->travel->dateFrom;
$existingDraft = $this->findDraft($user, $bookingId);
$bookingNumber = $bookingDto->booking?->bookingNumber;
$dateId = $bookingDto->travel->id;
$hotelId = $bookingDto->travel->hotelId;
if (null !== $existingDraft) {
$existingDraft->setFormData($formData);
// Only set once — the draft must stay bound to the original departure date and
// hotel, so these identifiers are never overwritten once assigned.
if (null === $existingDraft->getBookingNumber() && null !== $bookingNumber) {
$existingDraft->setBookingNumber($bookingNumber);
}
if (null === $existingDraft->getDateId() && null !== $dateId) {
$existingDraft->setDateId($dateId);
}
if (null === $existingDraft->getHotelId() && null !== $hotelId) {
$existingDraft->setHotelId($hotelId);
}
} else {
$draft = new BookingEditDraft($user, $bookingId, $travelDate, $formData);
$draft->setBookingNumber($bookingNumber);
$draft->setDateId($dateId);
$draft->setHotelId($hotelId);
$this->entityManager->persist($draft);
}
$this->entityManager->flush();
$this->logger->debug('Saved booking edit draft', [
'user_id' => $user->getId(),
'booking_id' => $bookingId,
]);
}
/**
* Deletes a draft after successful booking submission.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
*/
public function deleteDraft(User $user, int $bookingId): void
{
$this->draftRepository->deleteByUserAndBooking($user, $bookingId);
$this->logger->debug('Deleted booking edit draft', [
'user_id' => $user->getId(),
'booking_id' => $bookingId,
]);
}
/**
* Applies draft data to a fresh BookingDto loaded from the API.
*
* This method merges user input from the draft onto fresh API data.
* The draft contains personal data, service selections, and payment info
* that the user previously entered. Services are resolved by ID against
* the current Travel data to ensure prices and availability are current.
*
* @param BookingEditDraft $draft The draft containing saved user data
* @param BookingDto $dto The fresh BookingDto from API (modified in place)
* @param Travel $travel The current travel data for service resolution
*
* @return bool True if draft was applied successfully, false if draft data was invalid
*/
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
{
try {
$formData = $draft->getFormData();
// Apply payment data
if (isset($formData['paymentMethod'])) {
$dto->paymentMethod = $formData['paymentMethod'];
}
if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) {
$this->applyBankAccountData($dto, $formData['bankAccount']);
}
// Apply participant data (personal fields, body dimensions, room assignment,
// vouchers, and service selections — all gated by per-participant and travel mutability)
if (isset($formData['participants']) && true === is_array($formData['participants'])) {
foreach ($formData['participants'] as $index => $participantData) {
if (false === isset($dto->participants[$index])) {
continue;
}
$this->participantApplier->apply(
$dto,
$index,
$dto->participants[$index],
$participantData,
$travel,
);
}
}
$this->logger->info('Applied draft to booking DTO', [
'booking_id' => $draft->getBookingId(),
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
]);
return true;
} catch (\Throwable $e) {
$this->logger->error('Failed to apply draft to booking DTO', [
'booking_id' => $draft->getBookingId(),
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Applies bank account data from draft to BookingDto.
*/
private function applyBankAccountData(BookingDto $dto, array $bankAccountData): void
{
$iban = $bankAccountData['iban'] ?? null;
$accountHolder = $bankAccountData['accountHolder'] ?? null;
if (null === $iban && null === $accountHolder) {
return;
}
if (null === $dto->bankAccount) {
$dto->bankAccount = new BankAccountDto();
}
$dto->bankAccount->iban = $iban;
$dto->bankAccount->accountHolder = $accountHolder;
}
}
@@ -5,196 +5,165 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Travel;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\BookingEditDraftRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Manages draft persistence for the booking edit flow.
* Applies participant-level draft payloads onto booking DTO participants.
*
* This service handles saving, loading, and applying draft data to prevent
* data loss when BusProNet API rejects booking update submissions. Drafts
* are automatically applied on top of fresh API data when a user returns
* to edit a booking.
* Handles all per-participant draft fields: personal data, address, body dimensions,
* room assignment, license plate, vouchers, and service selections.
*
* Service selections are gated by travel-level mutability flags (additionalServicesMutable,
* transportationServicesMutable, pickupsMutable). Insurance is always applied regardless
* of mutability. Merge strategy (only apply if resolves to a valid service) is used for
* single-select fields; overwrite strategy is used for multi-select and boolean fields.
*/
class BookingEditDraftService
class BookingEditDraftMerger
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly BookingFingerprintService $fingerprintService,
private readonly BookingEditDraftParticipantApplier $participantApplier,
private readonly LoggerInterface $logger,
) {
}
public function apply(
BookingDto $bookingDto,
int $participantIndex,
ParticipantDto $participant,
array $data,
Travel $travel,
): void {
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
/**
* Finds an existing draft for a user and booking combination.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
*
* @return BookingEditDraft|null The draft if found, null otherwise
*/
public function findDraft(User $user, int $bookingId): ?BookingEditDraft
{
return $this->draftRepository->findByUserAndBooking($user, $bookingId);
}
/**
* Saves or updates a draft for the given booking.
*
* Uses an upsert pattern: creates a new draft if none exists,
* or updates the existing draft with new form data.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
* @param BookingDto $bookingDto The booking DTO containing user edits
*/
public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void
{
$formData = $this->fingerprintService->extractUserData($bookingDto);
$travelDate = $bookingDto->travel->dateFrom;
$existingDraft = $this->findDraft($user, $bookingId);
$bookingNumber = $bookingDto->booking?->bookingNumber;
$dateId = $bookingDto->travel->id;
$hotelId = $bookingDto->travel->hotelId;
if (null !== $existingDraft) {
$existingDraft->setFormData($formData);
if (null === $existingDraft->getBookingNumber() && null !== $bookingNumber) {
$existingDraft->setBookingNumber($bookingNumber);
}
if (null === $existingDraft->getDateId() && null !== $dateId) {
$existingDraft->setDateId($dateId);
}
if (null === $existingDraft->getHotelId() && null !== $hotelId) {
$existingDraft->setHotelId($hotelId);
}
} else {
$draft = new BookingEditDraft($user, $bookingId, $travelDate, $formData);
$draft->setBookingNumber($bookingNumber);
$draft->setDateId($dateId);
$draft->setHotelId($hotelId);
$this->entityManager->persist($draft);
// Personal data
if (true === $canApplyPersonalDataDraft && isset($data['personalData']) && true === is_array($data['personalData'])) {
$this->applyPersonalData($participant, $data['personalData']);
}
$this->entityManager->flush();
// Address
if (true === $canApplyPersonalDataDraft && isset($data['address']) && true === is_array($data['address'])) {
$this->applyAddressData($participant, $data['address']);
}
$this->logger->debug('Saved booking edit draft', [
'user_id' => $user->getId(),
'booking_id' => $bookingId,
]);
// Body dimensions
if (true === isset($data['bodyDimensions']) && true === is_array($data['bodyDimensions'])) {
$this->applyBodyDimensions($participant, $data['bodyDimensions']);
}
// Room assignment
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
$this->applyRoomAssignment($participant, $data['roomAssignment']);
}
// License plate
if (true === array_key_exists('licensePlate', $data)) {
$participant->licensePlate = $data['licensePlate'];
}
// Vouchers
if (true === isset($data['vouchers']) && true === is_array($data['vouchers'])) {
$this->applyVoucherCodes($participant, $data['vouchers']);
}
// Service selections (gated per-category by travel mutability flags)
if (isset($data['services']) && true === is_array($data['services'])) {
$this->applyServiceSelections($participant, $data['services'], $travel);
}
}
/**
* Deletes a draft after successful booking submission.
*
* @param User $user The user who owns the draft
* @param int $bookingId The BusProNet booking ID
*/
public function deleteDraft(User $user, int $bookingId): void
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
{
$this->draftRepository->deleteByUserAndBooking($user, $bookingId);
$this->logger->debug('Deleted booking edit draft', [
'user_id' => $user->getId(),
'booking_id' => $bookingId,
]);
}
/**
* Applies draft data to a fresh BookingDto loaded from the API.
*
* This method merges user input from the draft onto fresh API data.
* The draft contains personal data, service selections, and payment info
* that the user previously entered. Services are resolved by ID against
* the current Travel data to ensure prices and availability are current.
*
* @param BookingEditDraft $draft The draft containing saved user data
* @param BookingDto $dto The fresh BookingDto from API (modified in place)
* @param Travel $travel The current travel data for service resolution
*
* @return bool True if draft was applied successfully, false if draft data was invalid
*/
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
{
try {
$formData = $draft->getFormData();
// Apply payment data
if (isset($formData['paymentMethod'])) {
$dto->paymentMethod = $formData['paymentMethod'];
}
if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) {
$this->applyBankAccountData($dto, $formData['bankAccount']);
}
// Apply participant data
if (isset($formData['participants']) && true === is_array($formData['participants'])) {
foreach ($formData['participants'] as $index => $participantData) {
// Only apply to participants that exist in fresh DTO
if (false === isset($dto->participants[$index])) {
continue;
}
$this->participantApplier->apply(
$dto,
$index,
$dto->participants[$index],
$participantData,
);
if (isset($participantData['services']) && true === is_array($participantData['services'])) {
$this->applyServiceSelections($dto->participants[$index], $participantData['services'], $travel);
}
}
}
$this->logger->info('Applied draft to booking DTO', [
'booking_id' => $draft->getBookingId(),
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
]);
if (true === $bookingDto->isInternalAgencyBooking()) {
return true;
} catch (\Throwable $e) {
$this->logger->error('Failed to apply draft to booking DTO', [
'booking_id' => $draft->getBookingId(),
'error' => $e->getMessage(),
]);
}
if (0 === $participantIndex) {
return false;
}
return $participant->mutable;
}
/**
* Applies bank account data from draft to BookingDto.
*/
private function applyBankAccountData(BookingDto $dto, array $bankAccountData): void
private function applyPersonalData(ParticipantDto $participant, array $data): void
{
$iban = $bankAccountData['iban'] ?? null;
$accountHolder = $bankAccountData['accountHolder'] ?? null;
if (true === array_key_exists('firstName', $data)) {
$participant->firstName = $data['firstName'];
}
if (true === array_key_exists('lastName', $data)) {
$participant->lastName = $data['lastName'];
}
if (true === array_key_exists('dateOfBirth', $data) && null !== $data['dateOfBirth']) {
$participant->dateOfBirth = new \DateTimeImmutable($data['dateOfBirth']);
}
if (true === array_key_exists('email', $data)) {
$participant->email = $data['email'];
}
if (true === array_key_exists('mobile', $data)) {
$participant->mobile = $data['mobile'];
}
if (true === array_key_exists('gender', $data)) {
$participant->gender = $data['gender'];
}
if (true === array_key_exists('nationality', $data) && '' !== $data['nationality'] && null !== $data['nationality']) {
$participant->nationality = $data['nationality'];
}
}
if (null === $iban && null === $accountHolder) {
private function applyAddressData(ParticipantDto $participant, array $data): void
{
$hasAddressData = null !== ($data['street'] ?? null)
|| null !== ($data['postCode'] ?? null)
|| null !== ($data['city'] ?? null)
|| null !== ($data['country'] ?? null);
if (false === $hasAddressData) {
return;
}
if (null === $dto->bankAccount) {
$dto->bankAccount = new BankAccountDto();
if (null === $participant->address) {
$participant->address = new \App\BusProNet\Model\Address();
}
$dto->bankAccount->iban = $iban;
$dto->bankAccount->accountHolder = $accountHolder;
if (true === array_key_exists('street', $data)) {
$participant->address->street = $data['street'];
}
if (true === array_key_exists('postCode', $data)) {
$participant->address->postCode = $data['postCode'];
}
if (true === array_key_exists('city', $data)) {
$participant->address->city = $data['city'];
}
if (true === array_key_exists('country', $data)) {
$participant->address->country = $data['country'];
}
}
private function applyBodyDimensions(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('height', $data)) {
$participant->height = $data['height'];
}
if (true === array_key_exists('weight', $data)) {
$participant->weight = $data['weight'];
}
if (true === array_key_exists('shoeSize', $data)) {
$participant->shoeSize = $data['shoeSize'];
}
}
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('assignedRoomId', $data)) {
$participant->assignedRoomId = $data['assignedRoomId'];
}
if (true === array_key_exists('remarksRoom', $data)) {
$participant->remarksRoom = $data['remarksRoom'];
}
}
private function applyVoucherCodes(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('purchaseVoucherCode', $data)) {
$participant->purchaseVoucherCode = $data['purchaseVoucherCode'];
}
if (true === array_key_exists('promoVoucherCode', $data)) {
$participant->promoVoucherCode = $data['promoVoucherCode'];
}
}
/**
@@ -216,9 +185,7 @@ class BookingEditDraftService
if (true === $travel->additionalServicesMutable) {
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
$draftSkiPassId = $data['skiPass'];
$resolved = $this->resolveService($draftSkiPassId, $travel->additionalServices);
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices);
if (null !== $resolved) {
$participant->skiPass = $resolved;
}
@@ -345,21 +312,16 @@ class BookingEditDraftService
}
}
// Add mandatory services from original API data that aren't in draft
// Check mandatory status from travel data (pflicht attribute)
// Add mandatory services from original API data that aren't in draft.
// Check mandatory status from travel data (pflicht attribute), since the
// booking entity itself does not carry the mandatory flag.
foreach ($originalServices as $service) {
if (false === isset($draftServiceIds[$service->id])) {
// Look up mandatory status from travel data
$travelService = $travel->additionalServices[$service->id] ?? null;
$isMandatory = null !== $travelService && true === $travelService->mandatory;
if ($isMandatory) {
$draftServices[] = $service;
$this->logger->debug('Preserved mandatory service from API during draft application', [
'service_id' => $service->id,
'service_label' => $service->label,
]);
}
}
}
@@ -380,7 +342,7 @@ class BookingEditDraftService
}
/**
* @param array<int, int> $serviceIds
* @param array<int, int> $serviceIds
* @param array<int, object> $services
*
* @return array<int, object>
@@ -406,6 +368,7 @@ class BookingEditDraftService
return null;
}
// Pickups and drop-offs share the same ID space; a pickup ID may appear in either map
return $travel->pickups[$pickupId] ?? $travel->dropOffs[$pickupId] ?? null;
}
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Applies participant-level draft payloads onto booking DTO participants.
*/
class BookingEditDraftParticipantApplier
{
public function apply(
BookingDto $bookingDto,
int $participantIndex,
ParticipantDto $participant,
array $data,
): void {
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
// Personal data
if (true === $canApplyPersonalDataDraft && isset($data['personalData']) && true === is_array($data['personalData'])) {
$this->applyPersonalData($participant, $data['personalData']);
}
// Address
if (true === $canApplyPersonalDataDraft && isset($data['address']) && true === is_array($data['address'])) {
$this->applyAddressData($participant, $data['address']);
}
// Body dimensions
if (true === isset($data['bodyDimensions']) && true === is_array($data['bodyDimensions'])) {
$this->applyBodyDimensions($participant, $data['bodyDimensions']);
}
// Room assignment
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
$this->applyRoomAssignment($participant, $data['roomAssignment']);
}
// License plate
if (true === array_key_exists('licensePlate', $data)) {
$participant->licensePlate = $data['licensePlate'];
}
// Vouchers
if (true === isset($data['vouchers']) && true === is_array($data['vouchers'])) {
$this->applyVoucherCodes($participant, $data['vouchers']);
}
}
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
{
if (true === $bookingDto->isInternalAgencyBooking()) {
return true;
}
if (0 === $participantIndex) {
return false;
}
return $participant->mutable;
}
private function applyPersonalData(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('firstName', $data)) {
$participant->firstName = $data['firstName'];
}
if (true === array_key_exists('lastName', $data)) {
$participant->lastName = $data['lastName'];
}
if (true === array_key_exists('dateOfBirth', $data) && null !== $data['dateOfBirth']) {
$participant->dateOfBirth = new \DateTimeImmutable($data['dateOfBirth']);
}
if (true === array_key_exists('email', $data)) {
$participant->email = $data['email'];
}
if (true === array_key_exists('mobile', $data)) {
$participant->mobile = $data['mobile'];
}
if (true === array_key_exists('gender', $data)) {
$participant->gender = $data['gender'];
}
if (true === array_key_exists('nationality', $data) && '' !== $data['nationality'] && null !== $data['nationality']) {
$participant->nationality = $data['nationality'];
}
}
private function applyAddressData(ParticipantDto $participant, array $data): void
{
$hasAddressData = null !== ($data['street'] ?? null)
|| null !== ($data['postCode'] ?? null)
|| null !== ($data['city'] ?? null)
|| null !== ($data['country'] ?? null);
if (false === $hasAddressData) {
return;
}
if (null === $participant->address) {
$participant->address = new \App\BusProNet\Model\Address();
}
if (true === array_key_exists('street', $data)) {
$participant->address->street = $data['street'];
}
if (true === array_key_exists('postCode', $data)) {
$participant->address->postCode = $data['postCode'];
}
if (true === array_key_exists('city', $data)) {
$participant->address->city = $data['city'];
}
if (true === array_key_exists('country', $data)) {
$participant->address->country = $data['country'];
}
}
private function applyBodyDimensions(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('height', $data)) {
$participant->height = $data['height'];
}
if (true === array_key_exists('weight', $data)) {
$participant->weight = $data['weight'];
}
if (true === array_key_exists('shoeSize', $data)) {
$participant->shoeSize = $data['shoeSize'];
}
}
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('assignedRoomId', $data)) {
$participant->assignedRoomId = $data['assignedRoomId'];
}
if (true === array_key_exists('remarksRoom', $data)) {
$participant->remarksRoom = $data['remarksRoom'];
}
}
private function applyVoucherCodes(ParticipantDto $participant, array $data): void
{
if (true === array_key_exists('purchaseVoucherCode', $data)) {
$participant->purchaseVoucherCode = $data['purchaseVoucherCode'];
}
if (true === array_key_exists('promoVoucherCode', $data)) {
$participant->promoVoucherCode = $data['promoVoucherCode'];
}
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Keeps participant-count shaping separate from booking orchestration.
*/
class BookingParticipantCountService
{
/**
* Ensures the booking DTO has the expected number of participant objects.
*
*/
public function ensureCorrectNumberOfParticipants(BookingDto $bookingDto): void
{
$participantsCount = $this->calculateParticipantsCount($bookingDto->roomSelections, $bookingDto->travel);
$existingParticipants = $bookingDto->participants;
$bookingDto->participants = [];
for ($i = 0; $i < $participantsCount; ++$i) {
$participant = $existingParticipants[$i] ?? new ParticipantDto();
$participant->index = $i;
$bookingDto->participants[$i] = $participant;
}
}
/**
* Calculates the total number of participants based on room selections.
*
* Multiplies each room's minimum occupancy (minPax) by the selected quantity
* to determine the total number of participants required for the booking.
*
* @param array $roomSelections Array of room selection DTOs
* @param Travel $travelData Travel data containing room information
*/
private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int
{
$participantsCount = 0;
$rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->id];
$participantsCount += $room->minPax * $roomSelection->quantity;
}
return $participantsCount;
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Room;
use App\Form\Model\RoomGroupsDto;
/**
* Groups room data for booking steps.
*/
class BookingRoomSelectionService
{
/**
* @param array<int, Room> $rooms
*/
public function groupRoomsBySelectionType(array $rooms): RoomGroupsDto
{
$groups = [
Room::SELECTION_TYPE_BY_PAX => [],
Room::SELECTION_TYPE_BY_ROOM => [],
];
foreach ($rooms as $room) {
if (false !== stripos($room->label, 'bett')) {
$groups[Room::SELECTION_TYPE_BY_PAX][$room->id] = $room;
continue;
}
$groups[Room::SELECTION_TYPE_BY_ROOM][$room->id] = $room;
}
uasort($groups[Room::SELECTION_TYPE_BY_PAX], static fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax);
uasort($groups[Room::SELECTION_TYPE_BY_ROOM], static fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax);
return new RoomGroupsDto(
byPax: $groups[Room::SELECTION_TYPE_BY_PAX],
byRoom: $groups[Room::SELECTION_TYPE_BY_ROOM],
);
}
}
@@ -23,12 +23,11 @@ use Symfony\Contracts\Cache\ItemInterface;
* Provides pricing breakdowns, room assignments, participant counts,
* and CMS product information in a single service.
*/
class BookingSummaryDataService
class BookingSummaryAssembler
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly BookingSummaryParticipantCountService $participantCountService,
private readonly CmsDataService $cmsDataService,
private readonly BookingPriceCalculator $priceCalculator,
private readonly CmsDataProvider $cmsDataService,
private readonly HotelLoader $hotelLoader,
private readonly CountryDataProvider $countryDataProvider,
private readonly CacheInterface $cache,
@@ -75,7 +74,7 @@ class BookingSummaryDataService
$cmsData = $this->getCmsDataForProduct($productCode, $hotelCode);
}
$participantCount = $this->participantCountService->calculate($bookingDto);
$participantCount = $this->calculateParticipantCount($bookingDto);
$acceptedVouchers = $bookingDto->getAcceptedVouchers($participantPrices);
@@ -177,4 +176,28 @@ class BookingSummaryDataService
return [] === $parts ? null : implode("\n", $parts);
}
/**
* Returns the participant count for the summary sidebar.
*
* In create step 1, derives the expected count from room selections.
* In later create steps and edit mode, uses the actual participant list.
*/
private function calculateParticipantCount(BookingDto $bookingDto): int
{
if (BookingDto::MODE_CREATE === $bookingDto->getMode() && 1 === $bookingDto->currentStep) {
$totalCapacity = 0;
$availableRooms = $bookingDto->travel->getAvailableRooms();
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->quantity > 0 && isset($availableRooms[$selection->id])) {
$room = $availableRooms[$selection->id];
$totalCapacity += $selection->quantity * ($room->maxPax ?? 0);
}
}
return $totalCapacity;
}
return count($bookingDto->participants);
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
/**
* Calculates the participant count shown in the summary sidebar.
*
* In create step 1, derive the expected participant count from room selections.
* In later create steps and edit mode, use the actual participant list.
*/
class BookingSummaryParticipantCountService
{
public function calculate(BookingDto $bookingDto): int
{
if (BookingDto::MODE_CREATE === $bookingDto->getMode() && 1 === $bookingDto->currentStep) {
return $this->calculateExpectedParticipantCountFromRooms($bookingDto);
}
return count($bookingDto->participants);
}
private function calculateExpectedParticipantCountFromRooms(BookingDto $bookingDto): int
{
$totalCapacity = 0;
$availableRooms = $bookingDto->travel->getAvailableRooms();
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->quantity > 0 && isset($availableRooms[$selection->id])) {
$room = $availableRooms[$selection->id];
$totalCapacity += $selection->quantity * ($room->maxPax ?? 0);
}
}
return $totalCapacity;
}
}
@@ -8,15 +8,13 @@ use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingCreateContext;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomGroupsDto;
use App\Form\Model\ParticipantCardDataDto;
use App\Form\Model\ParticipantCardPriceDto;
use App\Form\Model\BookingSummaryDto;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingRoomSelectionService;
use App\Service\ParticipantCardDataService;
use App\Service\BookingSummaryDataService;
use App\Service\BookingPriceCalculatorService;
use App\Service\ParticipantCardAssembler;
use App\Service\BookingSummaryAssembler;
use App\Service\BookingPriceCalculator;
use App\Service\RoomPricingCalculator;
use PHPUnit\Framework\TestCase;
@@ -47,7 +45,7 @@ class BookingCreateContextFactoryTest extends TestCase
$bookingDto = new BookingDto($travel, 157047);
$summaryData = $this->createMock(BookingSummaryDto::class);
$summaryDataService = $this->createMock(BookingSummaryDataService::class);
$summaryDataService = $this->createMock(BookingSummaryAssembler::class);
$summaryDataService->expects($this->once())
->method('getSummaryData')
->with(
@@ -56,26 +54,15 @@ class BookingCreateContextFactoryTest extends TestCase
)
->willReturn($summaryData);
$roomSelectionService = $this->createMock(BookingRoomSelectionService::class);
$groupedRooms = new RoomGroupsDto(
byPax: [$roomByPax->id => $roomByPax],
byRoom: [$roomByRoom->id => $roomByRoom],
);
$roomSelectionService->expects($this->once())
->method('groupRoomsBySelectionType')
->with([$roomByRoom->id => $roomByRoom, $roomByPax->id => $roomByPax])
->willReturn($groupedRooms);
$participantCardDataService = $this->createMock(ParticipantCardDataService::class);
$participantCardDataService = $this->createMock(ParticipantCardAssembler::class);
$participantCardDataService->expects($this->never())
->method('getAllCardsDataWithValidation');
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
$priceCalculator->expects($this->never())
->method('calculateAllParticipantIndividualPrices');
$service = new BookingCreateContextFactory(
$roomSelectionService,
$participantCardDataService,
$summaryDataService,
$priceCalculator,
@@ -86,9 +73,10 @@ class BookingCreateContextFactoryTest extends TestCase
$this->assertInstanceOf(BookingCreateContext::class, $context);
$this->assertSame($bookingDto, $context->bookingDto);
$this->assertSame($summaryData, $context->summaryData);
$this->assertSame(null, $context->cardsData);
$this->assertNull($context->cardsData);
$this->assertFalse($context->isSubmitted);
$this->assertSame($groupedRooms, $context->groupedRooms);
$this->assertSame([11 => $roomByPax], $context->groupedRooms->byPax);
$this->assertSame([10 => $roomByRoom], $context->groupedRooms->byRoom);
}
public function testCreateWithParticipantCardsBuildsStep2Context(): void
@@ -116,34 +104,23 @@ class BookingCreateContextFactoryTest extends TestCase
isCanceled: false,
);
$summaryDataService = $this->createMock(BookingSummaryDataService::class);
$summaryDataService = $this->createMock(BookingSummaryAssembler::class);
$summaryDataService->expects($this->once())
->method('getSummaryData')
->with($bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION)
->willReturn($summaryData);
$roomSelectionService = $this->createMock(BookingRoomSelectionService::class);
$groupedRooms = new RoomGroupsDto(
byPax: [],
byRoom: [$room->id => $room],
);
$roomSelectionService->expects($this->once())
->method('groupRoomsBySelectionType')
->with([$room->id => $room])
->willReturn($groupedRooms);
$participantCardDataService = $this->createMock(ParticipantCardDataService::class);
$participantCardDataService = $this->createMock(ParticipantCardAssembler::class);
$participantCardDataService->expects($this->once())
->method('getAllCardsDataWithValidation')
->with($bookingDto)
->willReturn([$cardData]);
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
$priceCalculator->expects($this->never())
->method('calculateAllParticipantIndividualPrices');
$service = new BookingCreateContextFactory(
$roomSelectionService,
$participantCardDataService,
$summaryDataService,
$priceCalculator,
@@ -154,6 +131,8 @@ class BookingCreateContextFactoryTest extends TestCase
$this->assertInstanceOf(BookingCreateContext::class, $context);
$this->assertSame([$cardData], $context->cardsData);
$this->assertTrue($context->isSubmitted);
$this->assertSame([], $context->groupedRooms->byPax);
$this->assertSame([10 => $room], $context->groupedRooms->byRoom);
}
public function testCreateWithParticipantPricesBuildsStep4Context(): void
@@ -174,34 +153,23 @@ class BookingCreateContextFactoryTest extends TestCase
$bookingDto = new BookingDto($travel, 157047);
$summaryData = $this->createMock(BookingSummaryDto::class);
$summaryDataService = $this->createMock(BookingSummaryDataService::class);
$summaryDataService = $this->createMock(BookingSummaryAssembler::class);
$summaryDataService->expects($this->once())
->method('getSummaryData')
->with($bookingDto, RoomPricingCalculator::PRICING_MODE_ASSIGNMENT)
->willReturn($summaryData);
$roomSelectionService = $this->createMock(BookingRoomSelectionService::class);
$groupedRooms = new RoomGroupsDto(
byPax: [],
byRoom: [$room->id => $room],
);
$roomSelectionService->expects($this->once())
->method('groupRoomsBySelectionType')
->with([$room->id => $room])
->willReturn($groupedRooms);
$participantCardDataService = $this->createMock(ParticipantCardDataService::class);
$participantCardDataService = $this->createMock(ParticipantCardAssembler::class);
$participantCardDataService->expects($this->never())
->method('getAllCardsDataWithValidation');
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
$priceCalculator->expects($this->once())
->method('calculateAllParticipantIndividualPrices')
->with($bookingDto)
->willReturn([123.45]);
$service = new BookingCreateContextFactory(
$roomSelectionService,
$participantCardDataService,
$summaryDataService,
$priceCalculator,
@@ -212,5 +180,6 @@ class BookingCreateContextFactoryTest extends TestCase
$this->assertInstanceOf(BookingCreateContext::class, $context);
$this->assertSame([123.45], $context->participantPrices);
$this->assertNull($context->cardsData);
$this->assertSame([10 => $room], $context->groupedRooms->byRoom);
}
}
@@ -13,9 +13,9 @@ use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\BookingEditDraftRepository;
use App\Service\BookingEditDraftService;
use App\Service\BookingEditDraftParticipantApplier;
use App\Service\BookingFingerprintService;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditDraftMerger;
use App\Service\BookingChangeTracker;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -27,17 +27,17 @@ use Psr\Log\NullLogger;
* service selections on the DTO. This prevents stale draft data from causing
* BusPro API rejections when submitting booking updates.
*/
class BookingEditDraftServiceMutabilityTest extends TestCase
class BookingEditDraftManagerMutabilityTest extends TestCase
{
private BookingEditDraftService $service;
private BookingEditDraftManager $service;
protected function setUp(): void
{
$this->service = new BookingEditDraftService(
$this->service = new BookingEditDraftManager(
$this->createMock(BookingEditDraftRepository::class),
$this->createMock(EntityManagerInterface::class),
$this->createMock(BookingFingerprintService::class),
new BookingEditDraftParticipantApplier(),
$this->createMock(BookingChangeTracker::class),
new BookingEditDraftMerger(),
new NullLogger(),
);
}
@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\BusProNet\Constants;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingParticipantCountService;
use PHPUnit\Framework\TestCase;
class BookingParticipantCountServiceTest extends TestCase
{
private BookingParticipantCountService $service;
protected function setUp(): void
{
$this->service = new BookingParticipantCountService();
}
public function testEnsureCorrectNumberOfParticipantsExpandsToCalculatedCount(): void
{
$travel = $this->createTravel([
1 => 2,
2 => 3,
]);
$bookingDto = new BookingDto($travel, 123);
$bookingDto->roomSelections = [
$this->createRoomSelection(1, 1),
$this->createRoomSelection(2, 2),
];
$bookingDto->participants = [new ParticipantDto()];
$this->service->ensureCorrectNumberOfParticipants($bookingDto);
$this->assertCount(8, $bookingDto->participants);
$this->assertSame(0, $bookingDto->participants[0]->index);
$this->assertSame(7, $bookingDto->participants[7]->index);
}
private function createTravel(array $roomCapacities): Travel
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2030-01-01');
$travel->dateTo = new \DateTimeImmutable('2030-01-06');
$rooms = [];
foreach ($roomCapacities as $id => $minPax) {
$room = new Room();
$room->id = $id;
$room->label = 'Room '.$id;
$room->minPax = $minPax;
$room->maxPax = $minPax + 1;
$room->available = 5;
$room->status = Constants::STATUS_AVAILABLE;
$rooms[$id] = $room;
}
$travel->rooms = $rooms;
return $travel;
}
private function createRoomSelection(int $id, int $quantity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->id = $id;
$selection->quantity = $quantity;
return $selection;
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Room;
use App\Form\Model\RoomGroupsDto;
use App\Service\BookingRoomSelectionService;
use PHPUnit\Framework\TestCase;
class BookingRoomSelectionServiceTest extends TestCase
{
private BookingRoomSelectionService $service;
protected function setUp(): void
{
$this->service = new BookingRoomSelectionService();
}
public function testGroupRoomsBySelectionTypeSplitsAndSortsRooms(): void
{
$rooms = [
10 => $this->createRoom(10, '2 Bett Zimmer', 4),
11 => $this->createRoom(11, 'Suite', 2),
12 => $this->createRoom(12, '3 Bett Zimmer', 6),
];
$groups = $this->service->groupRoomsBySelectionType($rooms);
$this->assertInstanceOf(RoomGroupsDto::class, $groups);
$this->assertSame([10, 12], array_keys($groups->byPax));
$this->assertSame([11], array_keys($groups->byRoom));
}
private function createRoom(int $id, string $label, int $maxPax): Room
{
$room = new Room();
$room->id = $id;
$room->label = $label;
$room->maxPax = $maxPax;
return $room;
}
}
@@ -4,19 +4,27 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingSummaryParticipantCountService;
use App\Service\BookingPriceCalculator;
use App\Service\BookingSummaryAssembler;
use App\Service\CmsDataProvider;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\XmlLoader\HotelLoader;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Contracts\Cache\CacheInterface;
class BookingSummaryParticipantCountServiceTest extends TestCase
class BookingSummaryAssemblerTest extends TestCase
{
public function testCreateStep1UsesExpectedParticipantCountFromSelectedRooms(): void
{
$service = new BookingSummaryParticipantCountService();
$service = $this->createService();
$bookingDto = $this->createBookingDto();
$bookingDto->currentStep = 1;
$bookingDto->roomSelections = [
@@ -24,12 +32,15 @@ class BookingSummaryParticipantCountServiceTest extends TestCase
$this->createRoomSelection(11, 2),
];
self::assertSame(8, $service->calculate($bookingDto));
$summary = $service->getSummaryData($bookingDto);
$this->assertInstanceOf(BookingSummaryDto::class, $summary);
$this->assertSame(8, $summary->participantCount); // 1×2 + 2×3 = 8
}
public function testLaterCreateStepsUseActualParticipantCount(): void
{
$service = new BookingSummaryParticipantCountService();
$service = $this->createService();
$bookingDto = $this->createBookingDto();
$bookingDto->currentStep = 2;
$bookingDto->participants = [
@@ -38,20 +49,45 @@ class BookingSummaryParticipantCountServiceTest extends TestCase
new ParticipantDto(),
];
self::assertSame(3, $service->calculate($bookingDto));
$summary = $service->getSummaryData($bookingDto);
$this->assertSame(3, $summary->participantCount);
}
public function testEditModeUsesActualParticipantCount(): void
{
$service = new BookingSummaryParticipantCountService();
$service = $this->createService();
$bookingDto = $this->createBookingDto();
$bookingDto->booking = new \App\BusProNet\Model\Booking();
$bookingDto->booking = new Booking();
$bookingDto->participants = [
new ParticipantDto(),
new ParticipantDto(),
];
self::assertSame(2, $service->calculate($bookingDto));
$summary = $service->getSummaryData($bookingDto);
$this->assertSame(2, $summary->participantCount);
}
private function createService(): BookingSummaryAssembler
{
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
$priceCalculator->method('calculateAllParticipantIndividualPrices')->willReturn([]);
$priceCalculator->method('getPricingBreakdown')->willReturn([
'rooms' => [],
'services' => [],
'surcharges' => null,
'grandTotal' => 0.0,
]);
return new BookingSummaryAssembler(
$priceCalculator,
$this->createMock(CmsDataProvider::class),
$this->createMock(HotelLoader::class),
$this->createMock(CountryDataProvider::class),
$this->createMock(CacheInterface::class),
new NullLogger(),
);
}
private function createBookingDto(): BookingDto
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Booking;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingSummaryDataService;
use App\Service\BookingSummaryParticipantCountService;
use App\Service\CmsDataService;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\XmlLoader\HotelLoader;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Contracts\Cache\CacheInterface;
class BookingSummaryDataServiceTest extends TestCase
{
public function testCreateStep1UsesExpectedParticipantCountFromSelectedRooms(): void
{
$service = $this->createService(8);
$bookingDto = $this->createBookingDto();
$summary = $service->getSummaryData($bookingDto);
$this->assertInstanceOf(BookingSummaryDto::class, $summary);
$this->assertSame(8, $summary->participantCount);
}
public function testLaterCreateStepsUseActualParticipantCount(): void
{
$service = $this->createService(3);
$bookingDto = $this->createBookingDto();
$bookingDto->currentStep = 2;
$summary = $service->getSummaryData($bookingDto);
$this->assertSame(3, $summary->participantCount);
}
public function testEditModeUsesActualParticipantCount(): void
{
$service = $this->createService(2);
$bookingDto = $this->createBookingDto();
$bookingDto->booking = new Booking();
$summary = $service->getSummaryData($bookingDto);
$this->assertSame(2, $summary->participantCount);
}
private function createService(int $participantCount): BookingSummaryDataService
{
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$priceCalculator->method('calculateAllParticipantIndividualPrices')->willReturn([]);
$priceCalculator->method('getPricingBreakdown')->willReturn([
'rooms' => [],
'services' => [],
'surcharges' => null,
'grandTotal' => 0.0,
]);
$participantCountService = $this->createMock(BookingSummaryParticipantCountService::class);
$participantCountService->method('calculate')
->willReturn($participantCount);
return new BookingSummaryDataService(
$priceCalculator,
$participantCountService,
$this->createMock(CmsDataService::class),
$this->createMock(HotelLoader::class),
$this->createMock(CountryDataProvider::class),
$this->createMock(CacheInterface::class),
new NullLogger(),
);
}
private function createBookingDto(): BookingDto
{
$bookingDto = new BookingDto(new \App\BusProNet\Model\Travel(), 157047);
return $bookingDto;
}
}