feat: pre-flight check of agency bookings
addresses #869bqxr4q
This commit is contained in:
@@ -45,22 +45,29 @@ class BookingEditContextFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||
*/
|
||||
public function createOverviewContext(
|
||||
BookingDto $bookingDto,
|
||||
Booking $bookingData,
|
||||
bool $isDirty,
|
||||
bool $isSubmitted,
|
||||
bool $hasValidationErrors,
|
||||
bool $hasBlockingValidationErrors,
|
||||
array $missingValueLabelsByParticipantIndex = [],
|
||||
): BookingEditContext {
|
||||
$cardsData = $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto);
|
||||
|
||||
return new BookingEditContext(
|
||||
bookingDto: $bookingDto,
|
||||
bookingData: $bookingData,
|
||||
mutableData: BookingMutabilityDto::fromBaseData($this->travelDataService->getMutabilityData($bookingData->dateId)),
|
||||
summaryData: $this->createSummaryData($bookingDto),
|
||||
cardsData: $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto),
|
||||
cardsData: $cardsData,
|
||||
missingValueLabelsByParticipantIndex: $missingValueLabelsByParticipantIndex,
|
||||
isDirty: $isDirty,
|
||||
isSubmitted: $isSubmitted,
|
||||
hasValidationErrors: $hasValidationErrors,
|
||||
hasBlockingValidationErrors: $hasBlockingValidationErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ class BookingEditDraftMerger
|
||||
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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\ParticipantEditDto;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Detects edit-overview participants that still need user attention.
|
||||
*
|
||||
* This is display-only: it highlights participants that still need work
|
||||
* without affecting whether the edit submit is allowed.
|
||||
*
|
||||
* The scan uses the participant edit wrapper so participant-scoped validation
|
||||
* follows the same booking-context rules as the participant form. Body
|
||||
* dimensions are optional; only provided values are checked against the range
|
||||
* validator unless a selection-based exemption applies.
|
||||
*/
|
||||
class BookingEditPreFlightChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ValidatorInterface $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display-only attention scan for the edit overview.
|
||||
*
|
||||
* Canceled participants are ignored because they are not actionable.
|
||||
*
|
||||
* @return array<int, list<string>>
|
||||
*/
|
||||
public function findMissingValueLabelsByParticipantIndex(BookingDto $bookingDto): array
|
||||
{
|
||||
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$missingValueLabelsByParticipantIndex = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
if (true === $participant->isCanceled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingValueLabels = $this->getMissingValueLabelsForParticipant($bookingDto, $participant);
|
||||
|
||||
if ([] !== $missingValueLabels) {
|
||||
$missingValueLabelsByParticipantIndex[$index] = $missingValueLabels;
|
||||
}
|
||||
}
|
||||
|
||||
return $missingValueLabelsByParticipantIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getMissingValueLabelsForParticipant(BookingDto $bookingDto, ParticipantDto $participant): array
|
||||
{
|
||||
$participantForAudit = new ParticipantEditDto(
|
||||
participant: $participant,
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
$violations = $this->validator->validate(
|
||||
$participantForAudit,
|
||||
null,
|
||||
['booking_edit', 'strict_required']
|
||||
);
|
||||
|
||||
$valueLabels = [];
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$valueLabel = $this->mapViolationToValueLabel($violation);
|
||||
if (null !== $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $bookingDto->isInternalAgencyBooking() && true === $participant->isApplicant()) {
|
||||
foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($valueLabels));
|
||||
}
|
||||
|
||||
private function mapViolationToValueLabel(ConstraintViolationInterface $violation): ?string
|
||||
{
|
||||
$propertyPath = $violation->getPropertyPath();
|
||||
|
||||
if (str_starts_with($propertyPath, 'participant.')) {
|
||||
$propertyPath = substr($propertyPath, strlen('participant.'));
|
||||
}
|
||||
|
||||
return match ($propertyPath) {
|
||||
'firstName' => 'Vorname',
|
||||
'lastName' => 'Nachname',
|
||||
'gender' => 'Anrede',
|
||||
'nationality' => 'Nationalität',
|
||||
'dateOfBirth' => 'Geburtsdatum',
|
||||
'email' => 'E-Mail',
|
||||
'height' => 'Körpergröße',
|
||||
'weight' => 'Gewicht',
|
||||
'shoeSize' => 'Schuhgröße',
|
||||
'assignedRoomId' => 'Zimmer',
|
||||
'transportationOutbound' => 'Anreise',
|
||||
'pickup' => 'Zustieg',
|
||||
'transportationInbound' => 'Abreise',
|
||||
'dropOff' => 'Ausstieg',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getMissingValueAddressLabels(ParticipantDto $participant): array
|
||||
{
|
||||
if (null === $participant->address) {
|
||||
return ['Adresse'];
|
||||
}
|
||||
|
||||
$valueLabels = [];
|
||||
$violations = $this->validator->validate($participant->address, null, ['personal_data']);
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$valueLabel = match ($violation->getPropertyPath()) {
|
||||
'street' => 'Straße',
|
||||
'postCode' => 'PLZ',
|
||||
'city' => 'Ort',
|
||||
'country' => 'Land',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $valueLabel) {
|
||||
$valueLabels[] = $valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($valueLabels));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ class ParticipantCardAssembler
|
||||
* Get card data for a single participant.
|
||||
*/
|
||||
public function getCardData(BookingDto $bookingDto, int $index): ParticipantCardDataDto
|
||||
{
|
||||
return $this->buildCardData($bookingDto, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function buildCardData(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
|
||||
@@ -45,7 +57,7 @@ class ParticipantCardAssembler
|
||||
$roomName = $this->getRoomName($bookingDto, $participant);
|
||||
|
||||
// Calculate pricing state for display
|
||||
$priceData = $this->getPriceData($bookingDto, $index);
|
||||
$priceData = $this->getPriceData($bookingDto, $index, $precomputedPrices);
|
||||
|
||||
// Check if participant is canceled
|
||||
$isCanceled = $participant->isCanceled();
|
||||
@@ -67,9 +79,10 @@ class ParticipantCardAssembler
|
||||
public function getAllCardsData(BookingDto $bookingDto): array
|
||||
{
|
||||
$cardsData = [];
|
||||
$prices = $this->calculateBulkParticipantPrices($bookingDto);
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardData($bookingDto, $index);
|
||||
$cardsData[$index] = $this->buildCardData($bookingDto, $index, $prices);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
@@ -122,8 +135,14 @@ class ParticipantCardAssembler
|
||||
*
|
||||
* Returns a dash marker when the price is zero and no room is assigned,
|
||||
* indicating incomplete configuration rather than a zero-cost booking.
|
||||
*
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function getPriceData(BookingDto $bookingDto, int $index): ParticipantCardPriceDto
|
||||
private function getPriceData(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardPriceDto
|
||||
{
|
||||
// Check if canceled (only possible in edit mode when booking property is set)
|
||||
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
|
||||
@@ -150,7 +169,7 @@ class ParticipantCardAssembler
|
||||
}
|
||||
|
||||
// For active participants: existing price calculation logic
|
||||
$prices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
$prices = $precomputedPrices ?? $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
|
||||
$price = $prices[$index] ?? 0.0;
|
||||
|
||||
@@ -166,8 +185,92 @@ class ParticipantCardAssembler
|
||||
/**
|
||||
* Get card data for a single participant with validation state.
|
||||
*/
|
||||
public function getCardDataWithValidation(BookingDto $bookingDto, int $index): ParticipantCardDataDto
|
||||
public function getCardDataWithValidation(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
bool $forceStrictRequired = false,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
return $this->getCardDataWithValidationInternal(
|
||||
$bookingDto,
|
||||
$index,
|
||||
$this->determineValidationGroups($bookingDto, $forceStrictRequired)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get card data for all participants with validation state.
|
||||
*
|
||||
* @return array<int, ParticipantCardDataDto>
|
||||
*/
|
||||
public function getAllCardsDataWithValidation(BookingDto $bookingDto, bool $forceStrictRequired = false): array
|
||||
{
|
||||
$cardsData = [];
|
||||
$prices = $this->calculateBulkParticipantPrices($bookingDto);
|
||||
$validationGroups = $this->determineValidationGroups($bookingDto, $forceStrictRequired);
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardDataWithValidationInternal(
|
||||
$bookingDto,
|
||||
$index,
|
||||
$validationGroups,
|
||||
$prices
|
||||
);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function calculateBulkParticipantPrices(BookingDto $bookingDto): array
|
||||
{
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
if (($bookingDto->booking?->participantsStatus[$index] ?? null) !== 'S') {
|
||||
return $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines validation groups based on booking mode and applicant mutability.
|
||||
*
|
||||
* @return array<string> Validation groups to apply
|
||||
*/
|
||||
private function determineValidationGroups(BookingDto $bookingDto, bool $forceStrictRequired = false): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
// Add mode-specific group
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
$groups[] = 'booking_create';
|
||||
$groups[] = 'strict_required';
|
||||
} else {
|
||||
$groups[] = 'booking_edit';
|
||||
|
||||
// Strict validation in edit mode except for internal agency bookings,
|
||||
// unless the caller explicitly asks for the full validation set.
|
||||
if (true === $forceStrictRequired || false === $bookingDto->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $validationGroups
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function getCardDataWithValidationInternal(
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
array $validationGroups,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardDataDto {
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
|
||||
if (null === $participant) {
|
||||
@@ -175,17 +278,13 @@ class ParticipantCardAssembler
|
||||
}
|
||||
|
||||
// Get basic card data
|
||||
$cardData = $this->getCardData($bookingDto, $index);
|
||||
$cardData = $this->buildCardData($bookingDto, $index, $precomputedPrices);
|
||||
|
||||
// Wrap participant for validation
|
||||
$wrapper = new ParticipantEditDto(
|
||||
participant: $participant,
|
||||
bookingContext: $bookingDto,
|
||||
);
|
||||
|
||||
// Determine validation groups based on mode and mutability
|
||||
$validationGroups = $this->determineValidationGroups($bookingDto);
|
||||
|
||||
// Validate the wrapper DTO
|
||||
$violations = $this->validator->validate($wrapper, null, $validationGroups);
|
||||
|
||||
@@ -199,45 +298,4 @@ class ParticipantCardAssembler
|
||||
|
||||
return $cardData->withValidation($isValid, $errorMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get card data for all participants with validation state.
|
||||
*
|
||||
* @return array<int, ParticipantCardDataDto>
|
||||
*/
|
||||
public function getAllCardsDataWithValidation(BookingDto $bookingDto): array
|
||||
{
|
||||
$cardsData = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $_participant) {
|
||||
$cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index);
|
||||
}
|
||||
|
||||
return $cardsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines validation groups based on booking mode and applicant mutability.
|
||||
*
|
||||
* @return array<string> Validation groups to apply
|
||||
*/
|
||||
private function determineValidationGroups(BookingDto $bookingDto): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
// Add mode-specific group
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
$groups[] = 'booking_create';
|
||||
$groups[] = 'strict_required';
|
||||
} else {
|
||||
$groups[] = 'booking_edit';
|
||||
|
||||
// Strict validation in edit mode except for internal agency bookings
|
||||
if (false === $bookingDto->isInternalAgencyBooking()) {
|
||||
$groups[] = 'strict_required';
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,15 +112,9 @@ class ParticipantDataPrefiller
|
||||
|
||||
/**
|
||||
* Checks if the participant's last name triggers dummy data fill.
|
||||
*
|
||||
* Only matches in create mode to avoid accidental triggering during edits.
|
||||
*/
|
||||
public function isDummyDataFillRequested(ParticipantDto $participant, string $mode): bool
|
||||
{
|
||||
if (BookingDto::MODE_CREATE !== $mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::TOKEN === $participant->lastName;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user