diff --git a/docs/service-simplification-plan.md b/docs/service-simplification-plan.md index fd087c5..d22b5b2 100644 --- a/docs/service-simplification-plan.md +++ b/docs/service-simplification-plan.md @@ -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 diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index ad82db8..ea3b990 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -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; + } } diff --git a/src/Service/BookingCreateContextFactory.php b/src/Service/BookingCreateContextFactory.php index d9a5a84..f8c6a82 100644 --- a/src/Service/BookingCreateContextFactory.php +++ b/src/Service/BookingCreateContextFactory.php @@ -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 $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], + ); + } } diff --git a/src/Service/BookingEditDraftManager.php b/src/Service/BookingEditDraftManager.php new file mode 100644 index 0000000..39dc0be --- /dev/null +++ b/src/Service/BookingEditDraftManager.php @@ -0,0 +1,197 @@ +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; + } +} diff --git a/src/Service/BookingEditDraftService.php b/src/Service/BookingEditDraftMerger.php similarity index 57% rename from src/Service/BookingEditDraftService.php rename to src/Service/BookingEditDraftMerger.php index 68b64a6..8ec05d1 100644 --- a/src/Service/BookingEditDraftService.php +++ b/src/Service/BookingEditDraftMerger.php @@ -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 $serviceIds + * @param array $serviceIds * @param array $services * * @return array @@ -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; } diff --git a/src/Service/BookingEditDraftParticipantApplier.php b/src/Service/BookingEditDraftParticipantApplier.php deleted file mode 100644 index 1acfba9..0000000 --- a/src/Service/BookingEditDraftParticipantApplier.php +++ /dev/null @@ -1,154 +0,0 @@ -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']; - } - } - -} diff --git a/src/Service/BookingParticipantCountService.php b/src/Service/BookingParticipantCountService.php deleted file mode 100644 index b5ba0e8..0000000 --- a/src/Service/BookingParticipantCountService.php +++ /dev/null @@ -1,56 +0,0 @@ -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; - } -} diff --git a/src/Service/BookingRoomSelectionService.php b/src/Service/BookingRoomSelectionService.php deleted file mode 100644 index 5b23dd2..0000000 --- a/src/Service/BookingRoomSelectionService.php +++ /dev/null @@ -1,42 +0,0 @@ - $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], - ); - } -} diff --git a/src/Service/BookingSummaryDataService.php b/src/Service/BookingSummaryAssembler.php similarity index 83% rename from src/Service/BookingSummaryDataService.php rename to src/Service/BookingSummaryAssembler.php index 9c63ce8..af12c9b 100644 --- a/src/Service/BookingSummaryDataService.php +++ b/src/Service/BookingSummaryAssembler.php @@ -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); + } } diff --git a/src/Service/BookingSummaryParticipantCountService.php b/src/Service/BookingSummaryParticipantCountService.php deleted file mode 100644 index e07c218..0000000 --- a/src/Service/BookingSummaryParticipantCountService.php +++ /dev/null @@ -1,40 +0,0 @@ -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; - } -} diff --git a/tests/Service/BookingCreateContextFactoryTest.php b/tests/Service/BookingCreateContextFactoryTest.php index c67aa3d..baf4b97 100644 --- a/tests/Service/BookingCreateContextFactoryTest.php +++ b/tests/Service/BookingCreateContextFactoryTest.php @@ -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); } } diff --git a/tests/Service/BookingEditDraftServiceMutabilityTest.php b/tests/Service/BookingEditDraftManagerMutabilityTest.php similarity index 97% rename from tests/Service/BookingEditDraftServiceMutabilityTest.php rename to tests/Service/BookingEditDraftManagerMutabilityTest.php index bc78531..3fcb60e 100644 --- a/tests/Service/BookingEditDraftServiceMutabilityTest.php +++ b/tests/Service/BookingEditDraftManagerMutabilityTest.php @@ -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(), ); } diff --git a/tests/Service/BookingParticipantCountServiceTest.php b/tests/Service/BookingParticipantCountServiceTest.php deleted file mode 100644 index 5d78995..0000000 --- a/tests/Service/BookingParticipantCountServiceTest.php +++ /dev/null @@ -1,77 +0,0 @@ -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; - } -} diff --git a/tests/Service/BookingRoomSelectionServiceTest.php b/tests/Service/BookingRoomSelectionServiceTest.php deleted file mode 100644 index 77972dc..0000000 --- a/tests/Service/BookingRoomSelectionServiceTest.php +++ /dev/null @@ -1,45 +0,0 @@ -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; - } -} diff --git a/tests/Service/BookingSummaryParticipantCountServiceTest.php b/tests/Service/BookingSummaryAssemblerTest.php similarity index 53% rename from tests/Service/BookingSummaryParticipantCountServiceTest.php rename to tests/Service/BookingSummaryAssemblerTest.php index 9f2663b..0f58b55 100644 --- a/tests/Service/BookingSummaryParticipantCountServiceTest.php +++ b/tests/Service/BookingSummaryAssemblerTest.php @@ -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 diff --git a/tests/Service/BookingSummaryDataServiceTest.php b/tests/Service/BookingSummaryDataServiceTest.php deleted file mode 100644 index 7e2cf7a..0000000 --- a/tests/Service/BookingSummaryDataServiceTest.php +++ /dev/null @@ -1,87 +0,0 @@ -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; - } -}