feat: pre-flight check of agency bookings

addresses #869bqxr4q
This commit is contained in:
Björn Fromme
2026-07-10 14:33:09 +02:00
parent f9142e3080
commit b41b19d4c6
26 changed files with 1818 additions and 304 deletions
@@ -15,6 +15,7 @@ use App\Service\BookingChangeTracker;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditPreFlightChecker;
use App\Service\BookingEditSubmitter;
use App\Service\BookingSessionManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -42,6 +43,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingChangeTracker $fingerprintService,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingEditPreFlightChecker $preFlightChecker,
private readonly BookingEditSubmitter $formSubmitter,
) {
}
@@ -107,8 +109,28 @@ class IndexController extends AbstractController
$form = $this->createForm(BookingEditType::class, $bookingDto);
$form->handleRequest($request);
// Pre-flight check is display-only: it helps users find participants that still need work
// without turning the overview into a blocking validation state.
$missingValueLabelsByParticipantIndex = $this->preFlightChecker
->findMissingValueLabelsByParticipantIndex($bookingDto);
// Keep these states separate:
// - $isOverviewSubmitted: the footer form was posted
// - $isOverviewFormValid: Symfony accepted the posted data
// - $hasBlockingValidationErrors: only non-internal bookings block submission
$isOverviewSubmitted = $form->isSubmitted();
$isOverviewFormValid = false;
if (true === $isOverviewSubmitted) {
$isOverviewFormValid = $form->isValid();
}
$hasBlockingValidationErrors = true === $isOverviewSubmitted
&& false === $isOverviewFormValid
&& false === $bookingDto->isInternalAgencyBooking();
// Handle form submission (clicking "Buchung aktualisieren")
if ($form->isSubmitted() && $form->isValid()) {
if (true === $isOverviewSubmitted && (true === $isOverviewFormValid || true === $bookingDto->isInternalAgencyBooking())) {
$submissionResult = $this->formSubmitter->handleSubmission($request, $bookingDto, $bookingId, $user);
$this->applySubmissionResultFlashes($submissionResult);
@@ -119,8 +141,9 @@ class IndexController extends AbstractController
$bookingDto,
$bookingData,
$this->fingerprintService->isDirty($bookingDto),
$form->isSubmitted(),
$form->isSubmitted() && false === $form->isValid(),
$isOverviewSubmitted,
$hasBlockingValidationErrors,
$missingValueLabelsByParticipantIndex,
);
$templateData = [
@@ -13,9 +13,11 @@ use App\Htmx\HxTrait;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\BookingSessionManager;
use App\Service\ParticipantFormSupport;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -33,6 +35,7 @@ class ParticipantController extends AbstractController
private readonly BookingEditDraftManager $draftService,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingSessionManager $bookingSessionService,
private readonly ParticipantDataPrefiller $prepopulationService,
private readonly ParticipantFormSupport $participantFormSupportService,
) {
}
@@ -76,16 +79,26 @@ class ParticipantController extends AbstractController
$this->editContextFactory->prepareBookingDto($bookingDto);
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
$form = $this->createForm(
BookingParticipantType::class,
$wrapper,
$this->participantFormSupportService->getParticipantFormOptions($bookingDto)
);
$form = $this->createParticipantForm($bookingDto, $index);
$form->handleRequest($request);
$isDummyDataFill = $this->prepopulationService->isDummyDataFillRequested(
$bookingDto->participants[$index],
$bookingDto->getMode()
);
if (true === $form->isSubmitted() && true === $isDummyDataFill) {
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
$form = $this->createParticipantForm($bookingDto, $index);
return $this->renderParticipantForm($form, $index, $bookingDto, $bookingId, $bookingData);
}
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
if ($form->isSubmitted() && $form->isValid()) {
@@ -96,20 +109,7 @@ class ParticipantController extends AbstractController
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
return $this->render('booking/edit/participant.html.twig', [
'form' => $form,
'participantIndex' => $index,
'bookingEditContext' => $context,
'bookingDto' => $context->bookingDto,
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['bookingId' => $bookingId],
]);
return $this->renderParticipantForm($form, $index, $bookingDto, $bookingId, $bookingData);
}
/**
@@ -190,6 +190,48 @@ class ParticipantController extends AbstractController
}
}
/**
* @return FormInterface<mixed>
*/
private function createParticipantForm(BookingDto $bookingDto, int $index): FormInterface
{
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
return $this->createForm(
BookingParticipantType::class,
$wrapper,
$this->participantFormSupportService->getParticipantFormOptions($bookingDto)
);
}
/**
* @param Booking|null $bookingData
* @param FormInterface<mixed> $form
*/
private function renderParticipantForm(
FormInterface $form,
int $index,
BookingDto $bookingDto,
int $bookingId,
?Booking $bookingData,
): Response
{
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
return $this->render('booking/edit/participant.html.twig', [
'form' => $form,
'participantIndex' => $index,
'bookingEditContext' => $context,
'bookingDto' => $context->bookingDto,
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['bookingId' => $bookingId],
]);
}
private function isParticipantCanceled(Booking $bookingData, int $index): bool
{
return Booking::STATUS_CANCELED === ($bookingData->participantsStatus[$index] ?? null);