feat: consolidate micro-services and unify draft merge logic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+138
-175
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user