414 lines
17 KiB
PHP
414 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Model\Address;
|
|
use App\BusProNet\Model\Service;
|
|
use App\BusProNet\Model\Travel;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\ParticipantDto;
|
|
|
|
/**
|
|
* Applies participant-level draft payloads onto booking DTO participants.
|
|
*
|
|
* 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 BookingEditDraftMerger
|
|
{
|
|
/** @param array<string, mixed> $data */
|
|
public function apply(
|
|
BookingDto $bookingDto,
|
|
int $participantIndex,
|
|
ParticipantDto $participant,
|
|
array $data,
|
|
Travel $travel,
|
|
): 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']);
|
|
// TODO: Drop this once all legacy drafts no longer contain non-numeric body-dimension values.
|
|
$participant->normalizeBodyDimensions();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
$participant->normalizeLoadedData();
|
|
}
|
|
|
|
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
|
{
|
|
if (true === $bookingDto->isInternalAgencyBooking()) {
|
|
return true;
|
|
}
|
|
|
|
if (0 === $participantIndex) {
|
|
return false;
|
|
}
|
|
|
|
return $participant->mutable;
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
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'];
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
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'];
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
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'];
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
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'];
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
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'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/** @param array<string, mixed> $data */
|
|
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']) {
|
|
$resolved = $this->resolveService($data['skiPass'], $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);
|
|
}
|
|
|
|
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
|
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
|
$resolved = $this->resolveService($data['veg'], $travel->additionalServices);
|
|
if (null !== $resolved) {
|
|
$participant->veg = $resolved;
|
|
}
|
|
}
|
|
|
|
// 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'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 list<Service> $draftServices Services resolved from draft data
|
|
* @param list<Service> $originalServices Services from fresh API data (booking assignments)
|
|
* @param Travel $travel Travel data containing mandatory flag on services
|
|
*
|
|
* @return list<Service> 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), since the
|
|
// booking entity itself does not carry the mandatory flag.
|
|
foreach ($originalServices as $service) {
|
|
if (false === isset($draftServiceIds[$service->id])) {
|
|
$travelService = $travel->additionalServices[$service->id] ?? null;
|
|
$isMandatory = null !== $travelService && true === $travelService->mandatory;
|
|
|
|
if ($isMandatory) {
|
|
$draftServices[] = $service;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $draftServices;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, object> $services
|
|
*/
|
|
private function resolveService(?int $serviceId, array $services): ?object
|
|
{
|
|
if (null === $serviceId) {
|
|
return null;
|
|
}
|
|
|
|
return $services[$serviceId] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $serviceIds
|
|
* @param array<int, object> $services
|
|
*
|
|
* @return array<int, object>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
|
{
|
|
if (null === $pickupId) {
|
|
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;
|
|
}
|
|
|
|
private function resolveDropOff(?int $dropOffId, Travel $travel): ?object
|
|
{
|
|
if (null === $dropOffId) {
|
|
return null;
|
|
}
|
|
|
|
return $travel->dropOffs[$dropOffId] ?? null;
|
|
}
|
|
|
|
private function resolveInsurance(?string $insuranceId, Travel $travel): ?object
|
|
{
|
|
if (null === $insuranceId || '' === $insuranceId) {
|
|
return null;
|
|
}
|
|
|
|
return $travel->insurances[$insuranceId] ?? null;
|
|
}
|
|
}
|