feat: proper determination of agency initiated bookings
For booking that are agency initiated (groups) different rules apply concerning mutability and requirement of personal data and whether the first participant is the applicant or not. This commit adds logic to evaluate the agency id assigned to the booking data to decide.
This commit is contained in:
@@ -15,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
class AgencyLoader
|
||||
{
|
||||
public const DEFAULT_AGENCY_CODE = 'INTERN';
|
||||
public const INTERNAL_AGENCY_CODE = '0004';
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
|
||||
@@ -35,8 +35,8 @@ class BookingEditType extends AbstractType
|
||||
|
||||
$groups = ['booking_edit'];
|
||||
|
||||
// Edit mode: strict only if applicant is immutable
|
||||
if (false === ($data->participants[0]?->mutable ?? true)) {
|
||||
// Strict validation in edit mode except for internal agency bookings
|
||||
if (false === $data->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
|
||||
|
||||
@@ -162,10 +162,15 @@ class BookingParticipantType extends AbstractType
|
||||
// Helper to get field state or empty array
|
||||
$getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? [];
|
||||
|
||||
// Personal data fields are optional only for internal agency bookings in edit mode
|
||||
$personalDataOptional = BookingDto::MODE_EDIT === $bookingDto->getMode()
|
||||
&& $bookingDto->isInternalAgencyBooking();
|
||||
|
||||
// Add personal data fields with conditional inclusion for authenticated users
|
||||
if ($this->fieldStateProvider->shouldIncludeField('firstName', $bookingDto, $participantIndex)) {
|
||||
$form->add('firstName', TextType::class, $this->mergeFieldState([
|
||||
'label' => 'Vorname',
|
||||
'required' => !$personalDataOptional,
|
||||
'sanitize_html' => true,
|
||||
'property_path' => 'participant.firstName',
|
||||
'attr' => [
|
||||
@@ -177,6 +182,7 @@ class BookingParticipantType extends AbstractType
|
||||
if ($this->fieldStateProvider->shouldIncludeField('lastName', $bookingDto, $participantIndex)) {
|
||||
$form->add('lastName', TextType::class, $this->mergeFieldState([
|
||||
'label' => 'Nachname',
|
||||
'required' => !$personalDataOptional,
|
||||
'sanitize_html' => true,
|
||||
'property_path' => 'participant.lastName',
|
||||
'attr' => [
|
||||
@@ -188,6 +194,7 @@ class BookingParticipantType extends AbstractType
|
||||
if ($this->fieldStateProvider->shouldIncludeField('dateOfBirth', $bookingDto, $participantIndex)) {
|
||||
$form->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([
|
||||
'label' => 'Geburtsdatum',
|
||||
'required' => !$personalDataOptional,
|
||||
'widget' => 'text',
|
||||
'input' => 'datetime_immutable',
|
||||
'html5' => false,
|
||||
@@ -197,7 +204,7 @@ class BookingParticipantType extends AbstractType
|
||||
|
||||
if ($this->fieldStateProvider->shouldIncludeField('gender', $bookingDto, $participantIndex)) {
|
||||
$form->add('gender', ChoiceType::class, $this->mergeFieldState([
|
||||
'label' => 'Geschlecht',
|
||||
'label' => 'Gender',
|
||||
'required' => false,
|
||||
'placeholder' => 'keine Angabe',
|
||||
'choices' => [
|
||||
@@ -212,6 +219,7 @@ class BookingParticipantType extends AbstractType
|
||||
if ($this->fieldStateProvider->shouldIncludeField('nationality', $bookingDto, $participantIndex)) {
|
||||
$form->add('nationality', CountryType::class, $this->mergeFieldState([
|
||||
'label' => 'Nationalität',
|
||||
'required' => !$personalDataOptional,
|
||||
'property' => 'nationality',
|
||||
'preferred_choices' => ['D', 'A', 'CH'],
|
||||
'property_path' => 'participant.nationality',
|
||||
@@ -221,6 +229,7 @@ class BookingParticipantType extends AbstractType
|
||||
if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) {
|
||||
$form->add('email', EmailType::class, $this->mergeFieldState([
|
||||
'label' => 'E-Mail',
|
||||
'required' => !$personalDataOptional,
|
||||
'property_path' => 'participant.email',
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
@@ -457,9 +466,8 @@ class BookingParticipantType extends AbstractType
|
||||
} else {
|
||||
$groups[] = 'booking_edit';
|
||||
|
||||
// Determine if strict validation applies in edit mode
|
||||
// Edit mode with immutable applicant requires strict validation
|
||||
if (false === ($bookingContext->participants[0]?->mutable ?? true)) {
|
||||
// Strict validation in edit mode except for internal agency bookings
|
||||
if (false === $bookingContext->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Form\Model;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -49,6 +50,8 @@ class BookingDto
|
||||
|
||||
public ?int $agencyId = null;
|
||||
|
||||
public ?string $agencyCode = null;
|
||||
|
||||
/**
|
||||
* Booking status code for API submission.
|
||||
* Values: 'F' (Final/Frei), 'A' (Anfrage/Inquiry).
|
||||
@@ -310,4 +313,16 @@ class BookingDto
|
||||
|
||||
return $oldSnapshot !== $newSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this booking is initiated by the internal agency.
|
||||
*
|
||||
* Internal agency bookings have a different relationship between applicant
|
||||
* and first participant: the applicant is a staff member, not the first
|
||||
* participant. This affects personal data editability rules.
|
||||
*/
|
||||
public function isInternalAgencyBooking(): bool
|
||||
{
|
||||
return AgencyLoader::INTERNAL_AGENCY_CODE === $this->agencyCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that determines if personal data fields should be shown as static text.
|
||||
*
|
||||
* This condition protects the logged-in user's personal data from being modified
|
||||
* within the booking form. Instead, users should edit their data via the personal
|
||||
* data form to prevent:
|
||||
* - Creating duplicate customer records in BPN
|
||||
* - Disconnecting bookings from the user's account
|
||||
* - Causing data inconsistencies between booking and account data
|
||||
*
|
||||
* The condition compares the participant's personId with the booking applicant's
|
||||
* personId to determine if the participant IS the logged-in user:
|
||||
* - Customer-initiated bookings: Participant 0's personId matches applicant's personId
|
||||
* - Agency-initiated bookings: Participant 0's personId differs from applicant's personId
|
||||
*
|
||||
* In CREATE mode: Checks if participant has a personId (prepopulated from BPN account)
|
||||
* In EDIT mode: Compares participant's personId with applicant's personId
|
||||
*/
|
||||
class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if personal data fields should be shown as static text.
|
||||
*
|
||||
* In CREATE mode: Returns true when participant has a personId (prepopulated).
|
||||
* In EDIT mode: Returns true when participant's personId matches applicant's personId,
|
||||
* indicating the participant IS the logged-in user.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*
|
||||
* @return bool True if fields should be shown as static text
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In edit mode, compare participant's personId with applicant's personId
|
||||
// This distinguishes customer-initiated (match) from agency-initiated (no match) bookings
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
||||
$applicantPersonId = $bookingDto->booking?->applicant?->personId;
|
||||
if (null === $applicantPersonId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $participant->personId === $applicantPersonId;
|
||||
}
|
||||
|
||||
// In create mode, check if participant has personId (prepopulated from BPN account)
|
||||
return null !== $participant->personId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns field names that this condition depends on.
|
||||
*
|
||||
* This condition is based on personId which is set during prepopulation
|
||||
* and doesn't change during form interaction, so no field dependencies.
|
||||
*
|
||||
* @return string[] Empty array - no field dependencies
|
||||
*/
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable description of this condition.
|
||||
*
|
||||
* @return string Description of the authenticated user personal data protection logic
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Personal data is shown as static text for the logged-in user (edit via personal data form)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that determines if the first participant's personal data should be read-only.
|
||||
*
|
||||
* This condition evaluates read-only state based on agency type and authentication state,
|
||||
* replacing the previous personId-based detection with agency code-based detection.
|
||||
*
|
||||
* Business Rules:
|
||||
* - CREATE mode (anonymous user): First participant data is editable (user must fill it in)
|
||||
* - CREATE mode (logged-in, non-internal agency): First participant = applicant, data is read-only
|
||||
* - CREATE mode (logged-in, internal agency): First participant ≠ applicant, data is editable
|
||||
* - EDIT mode (internal agency): First participant ≠ applicant, data is editable (mutability applies separately)
|
||||
* - EDIT mode (non-internal agency): First participant = applicant, always read-only
|
||||
*
|
||||
* Non-first participants are never affected by this condition - they return false here
|
||||
* but may still be read-only via PersonalDataMutabilityCondition in edit mode.
|
||||
*/
|
||||
class FirstParticipantReadOnlyCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if personal data fields should be shown as static text for the first participant.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*
|
||||
* @return bool True if fields should be shown as static text (read-only)
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
// This condition only applies to first participant (agency-based read-only)
|
||||
// Non-first participants: return false here, but may still be read-only via PersonalDataMutabilityCondition
|
||||
if (0 !== $participantIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
// CREATE mode logic
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Anonymous user (no personId) = must fill in data = editable
|
||||
if (null === $participant?->personId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Internal agency = first participant ≠ applicant = editable
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Logged-in user with non-internal agency = data prepopulated = read-only
|
||||
return true;
|
||||
}
|
||||
|
||||
// EDIT mode logic
|
||||
// Internal agency = first participant ≠ applicant = editable (mutability check applies separately)
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-internal agency = first participant = applicant = always read-only
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns field names that this condition depends on.
|
||||
*
|
||||
* This condition is based on agency code and personId which are set during initialization
|
||||
* and don't change during form interaction, so no field dependencies.
|
||||
*
|
||||
* @return string[] Empty array - no field dependencies
|
||||
*/
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable description of this condition.
|
||||
*
|
||||
* @return string Description of the first participant read-only logic
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'First participant personal data is read-only for non-internal agency bookings with logged-in users';
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,13 @@ use App\Form\Service\Contract\FieldConditionInterface;
|
||||
/**
|
||||
* Condition that determines if personal data fields should be hidden based on BPN mutability flag.
|
||||
*
|
||||
* This condition is used in EDIT MODE ONLY to respect the BPN API's per-participant
|
||||
* mutability flag (`aenderungmoeglich`). When a participant's data is not mutable
|
||||
* (mutable=false), personal data fields should be hidden and displayed as static text.
|
||||
* This condition respects the BPN API's per-participant mutability flag (`aenderungmoeglich`).
|
||||
* When a participant's data is not mutable (mutable=false), personal data fields should be
|
||||
* hidden and displayed as static text.
|
||||
*
|
||||
* This differs from AuthenticatedUserPersonalDataCondition which is used in CREATE MODE
|
||||
* to protect authenticated applicant data.
|
||||
* This condition is combined with FirstParticipantReadOnlyCondition via OR in edit mode.
|
||||
* FirstParticipantReadOnlyCondition handles agency-based read-only logic for first participant,
|
||||
* while this condition applies BPN mutability rules to all participants.
|
||||
*
|
||||
* In edit mode, BPN determines mutability based on business rules (e.g., payment status,
|
||||
* booking state, etc.). We must respect this flag to prevent users from attempting to
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Form\Service;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\Form\Service\Abstract\AbstractFieldStateProvider;
|
||||
use App\Form\Service\Condition\AgeRangeCondition;
|
||||
use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition;
|
||||
use App\Form\Service\Condition\BabyAgeCondition;
|
||||
use App\Form\Service\Condition\BookingEligibilityCondition;
|
||||
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
|
||||
@@ -16,6 +15,7 @@ use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
|
||||
use App\Form\Service\Condition\MultipleParticipantsCondition;
|
||||
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
|
||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||
@@ -80,59 +80,60 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
$rentalCondition = new RentalSelectionCondition();
|
||||
$skiPassCondition = new SkiPassSelectionCondition();
|
||||
|
||||
// Authenticated user personal data protection
|
||||
// Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records)
|
||||
$authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition();
|
||||
// First participant read-only condition
|
||||
// Render personal data fields as static text for first participant in non-internal agency bookings
|
||||
// when user is logged in (data is prepopulated from BPN account)
|
||||
$firstParticipantReadOnlyCondition = new FirstParticipantReadOnlyCondition();
|
||||
|
||||
$this->fieldStateConditions['firstName'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['lastName'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['gender'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['nationality'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['dateOfBirth'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['email'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
// Mobile field: static text for authenticated users, required for first participant (guest bookings)
|
||||
// Mobile field: static text for first participant (when read-only), required for first participant (guest bookings)
|
||||
$this->fieldStateConditions['mobile'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
];
|
||||
|
||||
// Address subfields - must render as static text to prevent creating duplicate BPN records
|
||||
$this->fieldStateConditions['address.street'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.postCode'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.city'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.country'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.district'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
// Note: Body dimensions (height, weight, shoeSize) are NOT hidden for authenticated users
|
||||
@@ -280,9 +281,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
];
|
||||
|
||||
// Make address field required for first participant (index 0)
|
||||
// Also render as static text for authenticated users to prevent duplicate BPN records
|
||||
// Also render as static text for first participant when read-only
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
];
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Abstract\AbstractFieldStateProvider;
|
||||
use App\Form\Service\Condition\AdditionalServicesMutabilityCondition;
|
||||
use App\Form\Service\Condition\AgeRangeCondition;
|
||||
use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition;
|
||||
use App\Form\Service\Condition\BookingModeCondition;
|
||||
use App\Form\Service\Condition\CompositeCondition;
|
||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
|
||||
use App\Form\Service\Condition\PersonalDataMutabilityCondition;
|
||||
use App\Form\Service\Condition\PickupsMutabilityCondition;
|
||||
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
|
||||
@@ -50,17 +50,17 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
|
||||
|
||||
// Personal data protection in edit mode:
|
||||
// 1. Participant with personId (linked to BPN account) - edit via personal data form instead
|
||||
// 1. First participant in non-internal agency booking - always read-only (first participant = applicant)
|
||||
// 2. BPN mutability flag (mutable=false) - respects BPN business rules
|
||||
// Show fields as static text if EITHER condition is true
|
||||
$authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition();
|
||||
$firstParticipantReadOnlyCondition = new FirstParticipantReadOnlyCondition();
|
||||
$personalDataMutabilityCondition = new PersonalDataMutabilityCondition();
|
||||
$personalDataHiddenCondition = CompositeCondition::or(
|
||||
$authenticatedUserCondition,
|
||||
$firstParticipantReadOnlyCondition,
|
||||
$personalDataMutabilityCondition
|
||||
);
|
||||
|
||||
// Render all personal data fields as static text if authenticated user OR BPN indicates not mutable
|
||||
// Render all personal data fields as static text if first participant in non-internal agency OR BPN indicates not mutable
|
||||
// Using 'static_text' state (not 'hidden') so template renders values as static text
|
||||
$personalDataFields = [
|
||||
'firstName',
|
||||
@@ -77,12 +77,12 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
];
|
||||
}
|
||||
|
||||
// Address fields - render as static text if authenticated user OR participant not mutable
|
||||
// Address fields - render as static text if first participant in non-internal agency OR participant not mutable
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
// Address subfields - render as static text if authenticated user OR participant not mutable
|
||||
// Address subfields - render as static text if first participant in non-internal agency OR participant not mutable
|
||||
$this->fieldStateConditions['address.street'] = [
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
@@ -189,7 +189,9 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
$participant->assignedRoomId = null;
|
||||
|
||||
// Add notification for unassigned participant
|
||||
$participantLabel = $participant->isApplicant() ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
|
||||
// For internal agency bookings, first participant is not the applicant
|
||||
$isApplicant = $participant->isApplicant() && false === $bookingDto->isInternalAgencyBooking();
|
||||
$participantLabel = $isApplicant ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
|
||||
$participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel));
|
||||
|
||||
++$unassignedCount;
|
||||
|
||||
@@ -421,7 +421,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'label' => 'Für alle Teilnehmer buchen',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'data-description' => 'Der hier angezeigte Preis gilt nur für den/die Anmelder:in. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
|
||||
'data-description' => sprintf(
|
||||
'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
|
||||
$bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in'
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Security\Crypt;
|
||||
@@ -35,6 +36,7 @@ class BookingEditDataLoaderService
|
||||
private readonly BookingFingerprintService $fingerprintService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly BookingEditDraftService $draftService,
|
||||
private readonly AgencyLoader $agencyLoader,
|
||||
private readonly Crypt $crypt,
|
||||
private readonly TagAwareCacheInterface $bpnCache,
|
||||
) {
|
||||
@@ -125,6 +127,11 @@ class BookingEditDataLoaderService
|
||||
|
||||
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
|
||||
|
||||
// Set agency code for internal agency detection (used by field state conditions)
|
||||
$formData->agencyCode = null !== $bookingData->agencyId
|
||||
? $this->agencyLoader->loadById($bookingData->agencyId)?->code
|
||||
: null;
|
||||
|
||||
// Set original fingerprint BEFORE applying draft, so dirty detection
|
||||
// compares against the original API data (not the draft-modified data)
|
||||
$formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Service;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Form\Model\BookingDto;
|
||||
@@ -28,6 +29,7 @@ class BookingService
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||
private readonly AgencyLoader $agencyLoader,
|
||||
#[Autowire('%default_booking_status%')]
|
||||
private readonly string $defaultBookingStatus,
|
||||
) {
|
||||
@@ -267,6 +269,9 @@ class BookingService
|
||||
$bookingCreateDto->roomSelections = $roomSelections;
|
||||
$bookingCreateDto->currentStep = 1;
|
||||
$bookingCreateDto->agencyId = $agencyId;
|
||||
$bookingCreateDto->agencyCode = null !== $agencyId
|
||||
? $this->agencyLoader->loadById($agencyId)?->code
|
||||
: null;
|
||||
$bookingCreateDto->bookingStatus = $bookingStatus;
|
||||
|
||||
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
@@ -36,7 +36,7 @@ class ParticipantCardDataService
|
||||
}
|
||||
|
||||
// Extract participant name with fallback
|
||||
$name = $this->getParticipantName($participant, $index);
|
||||
$name = $this->getParticipantName($bookingDto, $participant, $index);
|
||||
|
||||
// Extract email
|
||||
$email = $participant->email ?? '';
|
||||
@@ -78,7 +78,7 @@ class ParticipantCardDataService
|
||||
/**
|
||||
* Get participant name with fallback to generic label.
|
||||
*/
|
||||
private function getParticipantName(object $participant, int $index): string
|
||||
private function getParticipantName(BookingDto $bookingDto, object $participant, int $index): string
|
||||
{
|
||||
$firstName = $participant->firstName ?? '';
|
||||
$lastName = $participant->lastName ?? '';
|
||||
@@ -86,7 +86,12 @@ class ParticipantCardDataService
|
||||
$name = trim($firstName.' '.$lastName);
|
||||
|
||||
if ('' === $name) {
|
||||
return 0 === $index ? 'Anmelder:in' : 'Teilnehmer:in';
|
||||
// For internal agency bookings, first participant is not the applicant
|
||||
if (0 === $index && false === $bookingDto->isInternalAgencyBooking()) {
|
||||
return 'Anmelder:in';
|
||||
}
|
||||
|
||||
return 'Teilnehmer:in';
|
||||
}
|
||||
|
||||
return $name;
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
{{ participantIndex + 1 }}
|
||||
</div>
|
||||
<div class="text-2xl">
|
||||
{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
|
||||
{{ participantIndex == 0 and not bookingDto.isInternalAgencyBooking() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
|
||||
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie Anmelder</span>
|
||||
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</span>
|
||||
</legend>
|
||||
{% if applicantInsurance %}
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
@@ -480,7 +480,7 @@
|
||||
{% else %}
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie Anmelder</td>
|
||||
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-24"></td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12"></td>
|
||||
</tr>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
|
||||
<h2 class="pb-4">
|
||||
Teilnehmer
|
||||
Teilnehmer:innen
|
||||
</h2>
|
||||
|
||||
{# Display form-level validation errors #}
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold flex justify-between items-center">
|
||||
<span>
|
||||
{{ participant.firstName }} {{ participant.lastName }}
|
||||
{% if loop.first %}<span class="text-sm font-normal">(Anmelder)</span>{% endif %}
|
||||
{% if loop.first and not bookingCreateDto.isInternalAgencyBooking() %}<span class="text-sm font-normal">(Anmelder:in)</span>{% endif %}
|
||||
</span>
|
||||
{% if participantPrices is defined and participantPrices[loop.index0] is defined %}
|
||||
<span>{{ participantPrices[loop.index0]|format_currency('EUR') }}</span>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{# Scrollable content #}
|
||||
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
|
||||
{% block participant_cards %}
|
||||
<h2 class="pb-4">Teilnehmer</h2>
|
||||
<h2 class="pb-4">Teilnehmer:innen</h2>
|
||||
|
||||
{% if isDirty %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Service;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
@@ -25,11 +26,14 @@ class BookingServiceBabyTest extends TestCase
|
||||
$travelDataService = $this->createMock(TravelDataService::class);
|
||||
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
|
||||
$agencyLoader = $this->createMock(AgencyLoader::class);
|
||||
|
||||
$this->bookingService = new BookingService(
|
||||
$travelDataService,
|
||||
$priceCalculator,
|
||||
$this->participantEligibilityService
|
||||
$this->participantEligibilityService,
|
||||
$agencyLoader,
|
||||
'F' // default booking status
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Service;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
@@ -27,11 +28,14 @@ class BookingServiceStatusTest extends TestCase
|
||||
$this->travelDataService = $this->createMock(TravelDataService::class);
|
||||
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$participantEligibility = $this->createMock(ParticipantEligibilityService::class);
|
||||
$agencyLoader = $this->createMock(AgencyLoader::class);
|
||||
|
||||
$this->bookingService = new BookingService(
|
||||
$this->travelDataService,
|
||||
$priceCalculator,
|
||||
$participantEligibility
|
||||
$participantEligibility,
|
||||
$agencyLoader,
|
||||
'F' // default booking status
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user