feat: consolidate micro-services and unify draft merge logic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user