Files
myep/src/Service/BookingEditDraftService.php
T

604 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Address;
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.
*
* 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 BookingEditDraftService
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly BookingFingerprintService $fingerprintService,
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);
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
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->applyParticipantData($dto, $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;
}
/**
* Applies participant data from draft to a ParticipantDto.
*/
private function applyParticipantData(BookingDto $bookingDto, int $participantIndex, array $data, Travel $travel): void
{
$participant = $bookingDto->participants[$participantIndex];
$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'];
}
// Services
if (true === isset($data['services']) && true === is_array($data['services'])) {
$this->applyServiceSelections($participant, $data['services'], $travel);
}
// Vouchers
if (true === isset($data['vouchers']) && true === is_array($data['vouchers'])) {
$this->applyVoucherCodes($participant, $data['vouchers']);
}
}
/**
* Determines whether personal/address draft data may be applied.
*
* Edit rules mirror UI mutability behavior:
* - Internal agency bookings may always update participant personal data
* - Non-internal agency: first participant is always read-only in edit flow
* - Other participants require participant-level mutability from BPN
*/
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
{
if (true === $bookingDto->isInternalAgencyBooking()) {
return true;
}
if (0 === $participantIndex) {
return false;
}
return $participant->mutable;
}
/**
* Applies personal data fields to participant.
*/
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'];
}
}
/**
* Applies address data to participant.
*/
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 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'];
}
}
/**
* Applies body dimension data to participant.
*/
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'];
}
}
/**
* Applies room assignment data to participant.
*/
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'];
}
}
/**
* Applies service selections to participant, resolving IDs against Travel data.
*
* Single-select fields (radio buttons) use a merge strategy: draft values are only applied
* if they resolve to a valid service. This preserves API data when:
* - The draft was saved before certain services were assigned
* - The draft contains service IDs that no longer exist in current travel data
*
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
* always replace API data, since users can intentionally clear these selections.
*/
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void
{
// Additional services category — only apply draft data when services are mutable.
// When immutable, the booking's current services must be preserved as-is to avoid
// API rejection (stale draft data could differ from the locked booking state).
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);
if (null !== $resolved) {
$participant->skiPass = $resolved;
}
}
// Courses (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices);
}
// Board (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices);
}
// Rentals (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices);
}
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices);
if (null !== $resolved) {
$participant->rentalInsurance = $resolved;
$participant->rentalInsuranceSelected = true;
}
}
// Additional services (array) - overwrite strategy with mandatory service preservation
// User can deselect optional services, but mandatory services from API must be preserved
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices);
$participant->additionalServices = $this->preserveMandatoryServices(
$resolvedFromDraft,
$participant->additionalServices,
$travel
);
}
}
// Transportation category — only apply draft data when transportation is mutable
if (true === $travel->transportationServicesMutable) {
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices);
if (null !== $resolved) {
$participant->transportationOutbound = $resolved;
}
}
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices);
if (null !== $resolved) {
$participant->transportationInbound = $resolved;
}
}
// Parking (boolean) - overwrite strategy: user can uncheck
if (true === array_key_exists('parking', $data)) {
$participant->parking = (bool) $data['parking'];
}
}
// Pickups category — only apply draft data when pickups are mutable
if (true === $travel->pickupsMutable) {
// Pickup (single) - merge strategy: only apply if resolves to valid pickup
if (true === array_key_exists('pickup', $data) && null !== $data['pickup']) {
$resolved = $this->resolvePickup($data['pickup'], $travel);
if (null !== $resolved) {
$participant->pickup = $resolved;
}
}
// Drop-off (single) - merge strategy: only apply if resolves to valid drop-off
if (true === array_key_exists('dropOff', $data) && null !== $data['dropOff']) {
$resolved = $this->resolveDropOff($data['dropOff'], $travel);
if (null !== $resolved) {
$participant->dropOff = $resolved;
$participant->differentDropOff = true;
}
}
}
// Insurance (not governed by mutability categories — always apply from draft)
if (true === array_key_exists('insurance', $data) && null !== $data['insurance']) {
$resolved = $this->resolveInsurance($data['insurance'], $travel);
if (null !== $resolved) {
$participant->insurance = $resolved;
}
}
// Bulk insurance booking (boolean) - overwrite strategy: user can uncheck
if (true === array_key_exists('bulkInsuranceBooking', $data)) {
$participant->bulkInsuranceBooking = (bool) $data['bulkInsuranceBooking'];
}
}
/**
* Applies voucher codes to participant.
*/
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'];
}
}
/**
* Resolves a single service ID to a Service object from the available services.
*
* @param int|null $serviceId The service ID to resolve
* @param array $services Available services indexed by ID
*
* @return object|null The service object if found, null otherwise
*/
private function resolveService(?int $serviceId, array $services): ?object
{
if (null === $serviceId) {
return null;
}
return $services[$serviceId] ?? null;
}
/**
* Resolves an array of service IDs to Service objects.
*
* @param array $serviceIds Array of service IDs
* @param array $services Available services indexed by ID
*
* @return array Array of resolved Service objects (missing services are skipped)
*/
private function resolveServiceArray(array $serviceIds, array $services): array
{
$resolved = [];
$addedIds = [];
foreach ($serviceIds as $serviceId) {
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) {
$resolved[] = $services[$serviceId];
$addedIds[$serviceId] = true;
}
}
return $resolved;
}
/**
* Resolves a pickup ID to a Pickup object.
*/
private function resolvePickup(?int $pickupId, Travel $travel): ?object
{
if (null === $pickupId) {
return null;
}
return $travel->pickups[$pickupId] ?? $travel->dropOffs[$pickupId] ?? null;
}
/**
* Resolves a drop-off ID to a Pickup object from the travel drop-offs list.
*/
private function resolveDropOff(?int $dropOffId, Travel $travel): ?object
{
if (null === $dropOffId) {
return null;
}
return $travel->dropOffs[$dropOffId] ?? null;
}
/**
* Resolves an insurance ID to an Insurance object.
*/
private function resolveInsurance(?string $insuranceId, Travel $travel): ?object
{
if (null === $insuranceId || '' === $insuranceId) {
return null;
}
return $travel->insurances[$insuranceId] ?? null;
}
/**
* Preserves mandatory services from API data when applying draft.
*
* Mandatory services (like Ortstaxe) cannot be deselected by users and must
* always be present in the booking. When a draft was created before the agency
* assigned mandatory services, this method ensures those services are preserved
* from the fresh API data rather than being overwritten with stale draft data.
*
* The mandatory flag must be looked up from travel data since booking data
* doesn't include the pflicht attribute.
*
* @param array $draftServices Services resolved from draft data
* @param array $originalServices Services from fresh API data (booking assignments)
* @param Travel $travel Travel data containing mandatory flag on services
*
* @return array Merged array with draft services plus any missing mandatory services
*/
private function preserveMandatoryServices(array $draftServices, array $originalServices, Travel $travel): array
{
// Build lookup of service IDs already in the draft
$draftServiceIds = [];
foreach ($draftServices as $service) {
if (null !== $service->id) {
$draftServiceIds[$service->id] = true;
}
}
// Add mandatory services from original API data that aren't in draft
// Check mandatory status from travel data (pflicht attribute)
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,
]);
}
}
}
return $draftServices;
}
}