diff --git a/migrations/Version20260819120000.php b/migrations/Version20260819120000.php new file mode 100644 index 0000000..487bd11 --- /dev/null +++ b/migrations/Version20260819120000.php @@ -0,0 +1,41 @@ +addSql("UPDATE accommodation_booking SET status = 'requested' WHERE status = 'open' AND access_link_issued_at IS NULL"); + + // The type described the lifecycle a second time and contradicted the status once an + // offer had been accepted. It now records only where the booking came from, which is + // true at every point of its life: an old `inquiry` went through an offer however far + // it got, an old `booking` was placed directly. + $this->addSql('ALTER TABLE accommodation_booking CHANGE type origin VARCHAR(20) NOT NULL'); + $this->addSql("UPDATE accommodation_booking SET origin = 'offer' WHERE origin = 'inquiry'"); + $this->addSql("UPDATE accommodation_booking SET origin = 'direct' WHERE origin = 'booking'"); + } + + public function down(Schema $schema): void + { + $this->addSql("UPDATE accommodation_booking SET origin = 'inquiry' WHERE origin = 'offer'"); + $this->addSql("UPDATE accommodation_booking SET origin = 'booking' WHERE origin = 'direct'"); + $this->addSql('ALTER TABLE accommodation_booking CHANGE origin type VARCHAR(20) NOT NULL'); + $this->addSql("UPDATE accommodation_booking SET status = 'open' WHERE status = 'requested'"); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/DiscardController.php b/src/Controller/Admin/AccommodationBooking/DiscardController.php new file mode 100644 index 0000000..a948d0d --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/DiscardController.php @@ -0,0 +1,56 @@ +isConfirmed() || $booking->isDiscarded()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('discard_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->discardBooking($booking); + + $this->addFlash('success', 'Die Buchung wurde abgesagt.'); + + $this->logger->info('Discarded accommodation booking', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_discard.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'discard_accommodation_booking_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index 323332d..f38b42e 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -7,7 +7,6 @@ namespace App\Controller\Admin\AccommodationBooking; use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; -use App\Enum\Groups\AccommodationBookingStatus; use App\Form\Admin\Groups\AccommodationBookingType; use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\BoardServiceRepository; @@ -93,8 +92,6 @@ class EditController extends AbstractController 'current_additional_services' => $currentAdditionalServices, 'assignable_managers' => $assignableManagers, ]); - $previousStatus = $booking->getStatus(); - $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -125,19 +122,12 @@ class EditController extends AbstractController $this->bookingService->refreshPriceSnapshot($booking); + // Editing never moves the booking on and never mails the customer: every status + // change is its own named action (Angebot senden, bestätigen, absagen), so the + // status, its timestamp and its email can no longer drift apart. $this->entityManager->persist($booking); $this->entityManager->flush(); - // Entering Offen is what the customer needs to hear about: the offer becomes - // viewable. Every other status stays silent here — in particular Bestätigt, which - // is only ever reached through the explicit confirm action so that the binding - // confirmation is never sent as a side effect of editing. - $notifiableStatuses = [AccommodationBookingStatus::Open]; - if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) { - $this->bookingService->issueAccessLink($booking); - $this->bookingService->sendCustomerConfirmationEmail($booking); - } - $this->addFlash('success', 'Die Buchung wurde aktualisiert'); $this->logger->info('Updated accommodation booking', [ diff --git a/src/Controller/Admin/AccommodationBooking/SendOfferController.php b/src/Controller/Admin/AccommodationBooking/SendOfferController.php new file mode 100644 index 0000000..5c495b1 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/SendOfferController.php @@ -0,0 +1,64 @@ +isDraft() && !$booking->isRequested()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('send_offer_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + // The offer only exists for the customer as the email carrying its link, so + // publishing it without a deliverable address would strand the booking in Offen. + if (null === $booking->getEmail() || '' === trim($booking->getEmail())) { + $this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um das Angebot zu versenden.'); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()])); + } + + $this->bookingService->sendOffer($booking); + + $this->addFlash('success', 'Das Angebot wurde dem Kunden per E-Mail zugestellt.'); + + $this->logger->info('Sent accommodation booking offer', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_send_offer.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'send_offer_accommodation_booking_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index 0861e60..96d47c5 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -94,7 +94,9 @@ class OfferController extends AbstractController return $this->redirectToOfferPage($request, $uuid); } - if (!$booking->isInquiry() || !$booking->isOpen()) { + // Offen is the one status that means an offer is out and awaiting acceptance, so it + // is the whole guard — mirroring acceptBooking(), which no-ops on anything else. + if (!$booking->isOpen()) { return $this->redirectToOfferPage($request, $uuid); } @@ -120,12 +122,13 @@ class OfferController extends AbstractController } /** - * A draft is not ready to be shown, and a discarded booking must not be viewable - * any more — in both cases the link behaves as if it had expired. + * A draft and an inquiry the office has not worked through yet are not ready to be + * shown, and a discarded booking must not be viewable any more — in all three cases + * the link behaves as if it had expired. */ private function isCustomerVisible(AccommodationBooking $booking): bool { - return !$booking->isDraft() && !$booking->isDiscarded(); + return !$booking->isDraft() && !$booking->isRequested() && !$booking->isDiscarded(); } /** diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php index 0c19801..7e46c69 100644 --- a/src/Controller/Groups/Step4Controller.php +++ b/src/Controller/Groups/Step4Controller.php @@ -153,7 +153,7 @@ class Step4Controller extends AbstractAccommodationController { $prices = $this->bookingService->loadPrices($dto, $accommodation); $booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services); - $this->addFlash('groups_booking_result', $booking->getType()->value); + $this->addFlash('groups_booking_result', $booking->getOrigin()->value); $this->sessionManager->clear($request); } } diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index d8eabdc..2c93be4 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -9,8 +9,8 @@ use App\Entity\BlameableEntityInterface; use App\Entity\TimestampableEntity; use App\Entity\TimestampableEntityInterface; use App\Entity\User; +use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingStatus; -use App\Enum\Groups\AccommodationBookingType; use App\Enum\Groups\AdditionalServiceType; use App\Repository\Groups\AccommodationBookingRepository; use Doctrine\DBAL\Types\Types; @@ -81,12 +81,12 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt private AccommodationBookingStatus $status = AccommodationBookingStatus::Draft; /** - * What kind of record this is — an offer the customer still has to accept, or a - * directly placed, binding booking. Independent of the status: an accepted offer - * keeps its Inquiry type, which is how offer-originated bookings stay recognisable. + * How this record came about — through an offer the customer had to accept, or as a + * booking the customer placed directly. Orthogonal to the status and never changed + * after creation, so it stays truthful at every point of the lifecycle. */ - #[ORM\Column(length: 20, enumType: AccommodationBookingType::class)] - private AccommodationBookingType $type = AccommodationBookingType::Inquiry; + #[ORM\Column(length: 20, enumType: AccommodationBookingOrigin::class)] + private AccommodationBookingOrigin $origin = AccommodationBookingOrigin::Offer; #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] private ?\DateTimeImmutable $accessLinkIssuedAt = null; @@ -118,14 +118,15 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt private ?string $lastName = null; /** - * The `offer` group covers the one field a customer-facing booking cannot do without: - * the access link and every notification are addressed to it. It is enforced on - * creation as soon as the booking is not a draft, while `edit` additionally demands - * the rest of the contact data. + * The one field a customer-facing booking cannot do without — the access link and every + * notification are addressed to it. A record on its way to the customer is validated with + * `edit`, which additionally demands the rest of the contact data; the actions that + * actually reach out (SendOfferController, ConfirmController) check the address again on + * their own, since a draft may legitimately be saved without one. */ #[ORM\Column(length: 255, nullable: true)] - #[Assert\NotBlank(groups: ['edit', 'offer'])] - #[Assert\Email(groups: ['edit', 'offer'])] + #[Assert\NotBlank(groups: ['edit'])] + #[Assert\Email(groups: ['edit'])] private ?string $email = null; #[ORM\Column(length: 50, nullable: true)] @@ -376,6 +377,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return AccommodationBookingStatus::Draft === $this->status; } + public function isRequested(): bool + { + return AccommodationBookingStatus::Requested === $this->status; + } + public function isOpen(): bool { return AccommodationBookingStatus::Open === $this->status; @@ -396,26 +402,37 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return AccommodationBookingStatus::Discarded === $this->status; } - public function getType(): AccommodationBookingType + public function getOrigin(): AccommodationBookingOrigin { - return $this->type; + return $this->origin; } - public function setType(AccommodationBookingType $type): self + public function setOrigin(AccommodationBookingOrigin $origin): self { - $this->type = $type; + $this->origin = $origin; return $this; } - public function isInquiry(): bool + public function isFromOffer(): bool { - return AccommodationBookingType::Inquiry === $this->type; + return AccommodationBookingOrigin::Offer === $this->origin; } - public function isBooking(): bool + public function isDirectBooking(): bool { - return AccommodationBookingType::Booking === $this->type; + return AccommodationBookingOrigin::Direct === $this->origin; + } + + /** + * What to call this record wherever it is named next to its status. An offer becomes a + * booking the moment the customer commits, which is exactly what acceptedAt records — so + * this can never contradict the status the way a stored type could. The origin is a + * separate question ("where did this come from") and keeps its own label. + */ + public function recordLabel(): string + { + return null !== $this->acceptedAt ? 'Buchung' : 'Angebot'; } public function getAccessLinkIssuedAt(): ?\DateTimeImmutable diff --git a/src/Enum/Groups/AccommodationBookingOrigin.php b/src/Enum/Groups/AccommodationBookingOrigin.php new file mode 100644 index 0000000..c004894 --- /dev/null +++ b/src/Enum/Groups/AccommodationBookingOrigin.php @@ -0,0 +1,23 @@ + 'Angebot', + self::Direct => 'Direktbuchung', + }; + } +} diff --git a/src/Enum/Groups/AccommodationBookingStatus.php b/src/Enum/Groups/AccommodationBookingStatus.php index 85a5d0f..4c3deb8 100644 --- a/src/Enum/Groups/AccommodationBookingStatus.php +++ b/src/Enum/Groups/AccommodationBookingStatus.php @@ -2,9 +2,17 @@ namespace App\Enum\Groups; +/** + * The single lifecycle of a booking record. Every case names exactly one state and + * exactly one party it is waiting on: Angefragt and Entwurf wait on the office to + * prepare an offer, Offen waits on the customer to accept it, Eingegangen waits on + * the office to confirm. Where the record came from is not encoded here — that is + * what AccommodationBookingOrigin is for. + */ enum AccommodationBookingStatus: string { case Draft = 'draft'; + case Requested = 'requested'; case Open = 'open'; case Received = 'received'; case Confirmed = 'confirmed'; @@ -14,6 +22,7 @@ enum AccommodationBookingStatus: string { return match ($this) { self::Draft => 'Entwurf', + self::Requested => 'Angefragt', self::Open => 'Offen', self::Received => 'Eingegangen', self::Confirmed => 'Bestätigt', diff --git a/src/Enum/Groups/AccommodationBookingType.php b/src/Enum/Groups/AccommodationBookingType.php deleted file mode 100644 index 3d0e82f..0000000 --- a/src/Enum/Groups/AccommodationBookingType.php +++ /dev/null @@ -1,17 +0,0 @@ - 'Anfrage', - self::Booking => 'Buchung', - }; - } -} diff --git a/src/Form/Admin/Filter/AccommodationBookingFilterType.php b/src/Form/Admin/Filter/AccommodationBookingFilterType.php index d86f338..a6b0fa9 100644 --- a/src/Form/Admin/Filter/AccommodationBookingFilterType.php +++ b/src/Form/Admin/Filter/AccommodationBookingFilterType.php @@ -6,8 +6,8 @@ namespace App\Form\Admin\Filter; use App\Entity\Groups\Accommodation; use App\Entity\User; +use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingStatus; -use App\Enum\Groups\AccommodationBookingType; use App\Form\Model\Filter\AccommodationBookingFilterDto; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -30,10 +30,10 @@ class AccommodationBookingFilterType extends AbstractListFilterType 'expanded' => true, 'required' => false, ]], - 'type' => [EnumType::class, [ - 'label' => 'Art', - 'class' => AccommodationBookingType::class, - 'choice_label' => static fn (AccommodationBookingType $type) => $type->label(), + 'origin' => [EnumType::class, [ + 'label' => 'Herkunft', + 'class' => AccommodationBookingOrigin::class, + 'choice_label' => static fn (AccommodationBookingOrigin $origin) => $origin->label(), 'multiple' => true, 'expanded' => true, 'required' => false, diff --git a/src/Form/Admin/Groups/AccommodationBookingCreateType.php b/src/Form/Admin/Groups/AccommodationBookingCreateType.php index f8046b6..bcd1988 100644 --- a/src/Form/Admin/Groups/AccommodationBookingCreateType.php +++ b/src/Form/Admin/Groups/AccommodationBookingCreateType.php @@ -6,15 +6,12 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; -use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EmailType; -use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -50,17 +47,9 @@ class AccommodationBookingCreateType extends AbstractType ->add('childrenCount', IntegerType::class, [ 'label' => 'davon Kinder', ]) - ->add('status', EnumType::class, [ - 'label' => 'Status', - 'class' => AccommodationBookingStatus::class, - 'choices' => [ - AccommodationBookingStatus::Draft, - AccommodationBookingStatus::Open, - ], - 'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(), - ]) - // Not marked required in HTML: a draft may be saved without it, and the - // validation group below enforces it for everything else. + // Not marked required in HTML: a new record is always an Entwurf, which is a + // scratch record nobody is pointed at yet. The address is enforced when the + // offer is actually sent — see SendOfferController. ->add('email', EmailType::class, [ 'label' => 'E-Mail', 'required' => false, @@ -73,15 +62,10 @@ class AccommodationBookingCreateType extends AbstractType { $resolver->setDefaults([ 'data_class' => AccommodationBooking::class, - // A draft is a scratch record, but anything the customer can be pointed at - // needs a reachable address — see the `offer` group on the entity. The rest of - // the contact data is collected on the edit page, which validates `edit`. - 'validation_groups' => static function (FormInterface $form): array { - /** @var AccommodationBooking $booking */ - $booking = $form->getData(); - - return $booking->isDraft() ? ['Default'] : ['Default', 'offer']; - }, + // Creation always yields an Entwurf, a scratch record the office fills in over + // time, so nothing beyond the basics is required here. The contact data is + // collected on the edit page, which validates `edit`. + 'validation_groups' => ['Default'], ]); } } diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php index b89d897..60a548f 100644 --- a/src/Form/Admin/Groups/AccommodationBookingType.php +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -8,13 +8,11 @@ use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; use App\Entity\User; -use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EmailType; -use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -137,11 +135,8 @@ class AccommodationBookingType extends AbstractType } $builder - ->add('status', EnumType::class, [ - 'label' => 'Status', - 'class' => AccommodationBookingStatus::class, - 'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(), - ]) + // No status field: the status is only ever moved by a named action, so that it + // can never end up set without the timestamp and email that belong to it. ->add('accommodationDiscount', IntegerType::class, [ 'label' => 'Rabatt Unterkunft (%)', 'required' => false, @@ -175,9 +170,9 @@ class AccommodationBookingType extends AbstractType $resolver->setDefaults([ 'data_class' => AccommodationBooking::class, // Entwurf is a scratch record the office fills in over time, and an Absage is - // never going anywhere — neither needs complete contact data. The groups are - // resolved after binding, so promoting a draft to Anfrage or Buchung enforces - // the contact data in the very same save. + // never going anywhere — neither needs complete contact data. Everything else, + // Angefragt included, has already been in contact with the customer and is + // expected to carry the full set. 'validation_groups' => static function (FormInterface $form): array { /** @var AccommodationBooking $booking */ $booking = $form->getData(); diff --git a/src/Form/Model/Filter/AccommodationBookingFilterDto.php b/src/Form/Model/Filter/AccommodationBookingFilterDto.php index d060a53..89f2416 100644 --- a/src/Form/Model/Filter/AccommodationBookingFilterDto.php +++ b/src/Form/Model/Filter/AccommodationBookingFilterDto.php @@ -6,8 +6,8 @@ namespace App\Form\Model\Filter; use App\Entity\Groups\Accommodation; use App\Entity\User; +use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingStatus; -use App\Enum\Groups\AccommodationBookingType; use App\Model\ListFilterChip; class AccommodationBookingFilterDto extends AbstractListFilterDto @@ -15,8 +15,8 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto /** @var AccommodationBookingStatus[] */ public array $status = []; - /** @var AccommodationBookingType[] */ - public array $type = []; + /** @var AccommodationBookingOrigin[] */ + public array $origin = []; public ?User $managedBy = null; @@ -38,15 +38,17 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto /** * The list as it presents itself to someone who has not filtered yet. * - * The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e. - * everything that may still need work — nothing whose stay is already over, and, for - * staff who are not group admins, only the bookings they are responsible for. + * The still-live statuses — drafts, inquiries awaiting an offer, offers out with the + * customer and bookings awaiting validation, i.e. everything that may still need work — + * nothing whose stay is already over, and, for staff who are not group admins, only the + * bookings they are responsible for. */ public static function defaults(bool $seesAllBookings, ?User $currentUser): self { $filter = new self(); $filter->status = [ AccommodationBookingStatus::Draft, + AccommodationBookingStatus::Requested, AccommodationBookingStatus::Open, AccommodationBookingStatus::Received, ]; @@ -72,11 +74,11 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto ); } - if ([] !== $this->type) { + if ([] !== $this->origin) { $chips[] = new ListFilterChip( - 'Art', - implode(', ', array_map(static fn (AccommodationBookingType $t) => $t->label(), $this->type)), - ['type'], + 'Herkunft', + implode(', ', array_map(static fn (AccommodationBookingOrigin $o) => $o->label(), $this->origin)), + ['origin'], ); } diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 1ddf4ea..328f52e 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -95,7 +95,7 @@ final readonly class AccommodationBookingApiResponse { $this->uuid = $booking->getUuid(); $this->status = $booking->getStatus()->value; - $this->type = $booking->getType()->value; + $this->type = $booking->getOrigin()->value; $this->dateFrom = $booking->getDateFrom(); $this->dateTo = $booking->getDateTo(); $this->nights = $booking->getNights(); diff --git a/src/Repository/Groups/AccommodationBookingRepository.php b/src/Repository/Groups/AccommodationBookingRepository.php index 8722c8f..142fbdd 100644 --- a/src/Repository/Groups/AccommodationBookingRepository.php +++ b/src/Repository/Groups/AccommodationBookingRepository.php @@ -84,8 +84,8 @@ class AccommodationBookingRepository extends ServiceEntityRepository $qb->andWhere('booking.status IN (:status)')->setParameter('status', $filter->status); } - if ([] !== $filter->type) { - $qb->andWhere('booking.type IN (:type)')->setParameter('type', $filter->type); + if ([] !== $filter->origin) { + $qb->andWhere('booking.origin IN (:origin)')->setParameter('origin', $filter->origin); } if ($filter->unassigned) { diff --git a/src/Service/AccommodationBookingPdfGenerator.php b/src/Service/AccommodationBookingPdfGenerator.php index 0c199aa..caf4f72 100644 --- a/src/Service/AccommodationBookingPdfGenerator.php +++ b/src/Service/AccommodationBookingPdfGenerator.php @@ -98,7 +98,8 @@ class AccommodationBookingPdfGenerator $groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString(); return sprintf( - 'buchung-%s%s.pdf', + '%s-%s%s.pdf', + mb_strtolower($booking->recordLabel()), $booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum', '' !== $groupName ? '-'.$groupName : '' ); diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index ee4e92a..c10d061 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -11,8 +11,8 @@ use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationPrice; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; +use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingStatus; -use App\Enum\Groups\AccommodationBookingType; use App\Form\Model\AccommodationBookingDto; use App\Model\AccommodationBookingQueryParams; use App\Model\CmsHotelData; @@ -268,12 +268,12 @@ class AccommodationBookingService $booking->setPaxCount($dto->paxCount); $booking->setMinorsCount($dto->minorsCount); $booking->setChildrenCount($dto->childrenCount); - // An inquiry arrives live — the office has nothing to prepare first, so it goes - // straight to Offen. A direct booking has been committed to by the customer but - // still awaits the office's validation, hence Eingegangen rather than Bestätigt. + // An inquiry still needs an offer the office has to prepare, so it waits at Angefragt + // until that offer goes out. A direct booking has been committed to by the customer + // but still awaits the office's validation, hence Eingegangen rather than Bestätigt. $isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry; - $booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking); - $booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Received); + $booking->setOrigin($isInquiry ? AccommodationBookingOrigin::Offer : AccommodationBookingOrigin::Direct); + $booking->setStatus($isInquiry ? AccommodationBookingStatus::Requested : AccommodationBookingStatus::Received); if (!$isInquiry) { $booking->setAcceptedAt(new \DateTimeImmutable()); } @@ -342,7 +342,7 @@ class AccommodationBookingService // A direct booking gets no customer mail here: it only reaches the customer once the // office has validated it and confirmed via confirmBooking(). Acknowledging an // inquiry is a different matter — it promises nothing, so it still goes out. - if ($booking->isInquiry()) { + if ($booking->isFromOffer()) { $this->sendCustomerConfirmationEmail($booking); } @@ -375,7 +375,7 @@ class AccommodationBookingService $this->accommodationEmail, sprintf( 'Neue Unterkunfts%s: %s', - $booking->isInquiry() ? 'anfrage' : 'buchung', + $booking->isDirectBooking() ? 'buchung' : 'anfrage', $booking->getAccommodation()?->getName(), ), 'email/accommodation_booking.html.twig', @@ -457,7 +457,7 @@ class AccommodationBookingService */ public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void { - if (!$booking->isBooking()) { + if (!$booking->isDirectBooking()) { return; } @@ -465,13 +465,14 @@ class AccommodationBookingService } /** - * Always sends a confirmation email to the customer, whether or not an access link - * exists yet — the template renders differently depending on accessLink being present, - * so the subject has to follow the same distinction. + * Serves both customer-facing messages of the pre-booking phase: the acknowledgement + * that an inquiry arrived, and the offer itself. Offen is the status that means the + * offer is out, so it is what decides the subject — the template makes the same + * distinction via accessLink, which is always issued as the offer goes out. */ public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void { - $subject = null !== $booking->getAccessLinkIssuedAt() + $subject = $booking->isOpen() ? 'Dein Angebot ist bereit' : 'Deine Anfrage ist bei uns eingegangen'; @@ -496,9 +497,45 @@ class AccommodationBookingService } /** - * Accepts an open offer and notifies the office. The type stays Anfrage — - * only the status moves to Eingegangen, so an offer-originated booking remains - * distinguishable from a direct one. + * Publishes the prepared offer: the single action that puts an offer in front of the + * customer, and the only transition into Offen. Issues the access link the email links + * to, so the two can never drift apart the way they could when sending was a side + * effect of editing. + * Idempotent — a no-op (including no email) for anything that is not awaiting an offer. + */ + public function sendOffer(AccommodationBooking $booking): void + { + if (!$booking->isDraft() && !$booking->isRequested()) { + return; + } + + $this->issueAccessLink($booking); + $booking->setStatus(AccommodationBookingStatus::Open); + $this->entityManager->flush(); + + $this->sendCustomerConfirmationEmail($booking); + } + + /** + * Closes a booking that is not going to happen. Deliberately silent: the office tells + * the customer itself, so this only records the outcome. + * Idempotent — a no-op for a booking that is already confirmed or discarded, since a + * released confirmation is binding and must not be withdrawn behind the customer's back. + */ + public function discardBooking(AccommodationBooking $booking): void + { + if ($booking->isConfirmed() || $booking->isDiscarded()) { + return; + } + + $booking->setStatus(AccommodationBookingStatus::Discarded); + $this->entityManager->flush(); + } + + /** + * Accepts an offer that is out with the customer and notifies the office. The origin + * stays untouched, so the booking remains recognisable as offer-originated for the + * rest of its life; only the status moves on to Eingegangen. * Idempotent — a no-op (including no emails) if the offer is not open any more. * * The customer deliberately gets no mail here: nothing is confirmed until the office @@ -509,7 +546,9 @@ class AccommodationBookingService */ public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void { - if (!$booking->isInquiry() || !$booking->isOpen()) { + // Offen means exactly one thing — an offer is out and awaiting this acceptance — + // so the status alone is the whole guard. + if (!$booking->isOpen()) { return; } diff --git a/templates/admin/accommodation_booking/_status_label.html.twig b/templates/admin/accommodation_booking/_status_label.html.twig index e4c3174..838cc88 100644 --- a/templates/admin/accommodation_booking/_status_label.html.twig +++ b/templates/admin/accommodation_booking/_status_label.html.twig @@ -1,5 +1 @@ -{#- Offen covers two situations that need different office action, told apart by the access - link: no link yet means the offer still has to be prepared, a link means the customer is - the one who has to act. Display only — both are the same status. -#} {{- booking.status.label -}} -{%- if booking.open %} · {{ booking.accessLinkIssuedAt is not null ? 'Angebot versendet' : 'Angebot ausstehend' }}{% endif -%} diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index 45e036f..6200c63 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -1,7 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} - Buchung {{ booking.groupName }} + {{ booking.recordLabel }} {{ booking.groupName }}

{{ booking.accommodation.name }}

{% form_theme form 'forms_admin.html.twig' %} @@ -39,9 +39,13 @@ {% endif %}

Status & Rabatt

-
- {{ form_row(form.status) }} -
+ {# Read-only: the status only ever moves through a named action on the detail page, + so that it cannot be set without the email and timestamp that belong to it. #} +

+ Status: + {% include 'admin/accommodation_booking/_status_label.html.twig' %} + · Herkunft: {{ booking.origin.label }} +

{% if booking.acceptedAt is not null %}

Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 872dd17..fae98dc 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -66,7 +66,7 @@ {% endif %} - {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} + {{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} {% set discounts = [] %} diff --git a/templates/admin/accommodation_booking/modal_create.html.twig b/templates/admin/accommodation_booking/modal_create.html.twig index b3bd912..eccc3d8 100644 --- a/templates/admin/accommodation_booking/modal_create.html.twig +++ b/templates/admin/accommodation_booking/modal_create.html.twig @@ -3,7 +3,7 @@ {% block content %}

- Neue Buchung + Neues Angebot
{{ form_start(form) }}
@@ -15,9 +15,6 @@ {{ form_row(form.paxCount) }} {{ form_row(form.minorsCount) }} {{ form_row(form.childrenCount) }} -
- {{ form_row(form.status) }} -
{{ form_row(form.email) }}
diff --git a/templates/admin/accommodation_booking/modal_discard.html.twig b/templates/admin/accommodation_booking/modal_discard.html.twig new file mode 100644 index 0000000..eed5d72 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_discard.html.twig @@ -0,0 +1,16 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}{{ booking.recordLabel }} absagen{% endblock %} + +{% block content %} +
+ Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für + {{ booking.groupName }} absagen? + {% if booking.open %} + Das versendete Angebot kann danach nicht mehr angenommen werden. + {% endif %} + Der Kunde wird nicht automatisch benachrichtigt. +
+{% endblock %} + +{% block button_confirm %}Absagen{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_filter.html.twig b/templates/admin/accommodation_booking/modal_filter.html.twig index afddd23..466c620 100644 --- a/templates/admin/accommodation_booking/modal_filter.html.twig +++ b/templates/admin/accommodation_booking/modal_filter.html.twig @@ -7,7 +7,7 @@ {{ form_row(form.dateFrom) }} {{ form_row(form.dateTo) }} {{ form_row(form.status) }} - {{ form_row(form.type) }} + {{ form_row(form.origin) }}
{{ form_row(form.accommodation) }}
diff --git a/templates/admin/accommodation_booking/modal_send_offer.html.twig b/templates/admin/accommodation_booking/modal_send_offer.html.twig new file mode 100644 index 0000000..0543166 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_send_offer.html.twig @@ -0,0 +1,12 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Angebot senden{% endblock %} + +{% block content %} +
+ Möchtest du das Angebot für {{ booking.groupName }} an {{ booking.email }} senden? + Der Kunde kann es anschließend über den Zugangslink annehmen. +
+{% endblock %} + +{% block button_confirm %}Angebot senden{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index a41666a..a0f8a5f 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -2,7 +2,7 @@ {% block content %} - {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} + {{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} {{ booking.groupName }} @@ -53,8 +53,8 @@
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)
{{ booking.childrenCount }}
{% endif %} -
Art
-
{{ booking.type.label }}
+
Herkunft
+
{{ booking.origin.label }}
Status
{% include 'admin/accommodation_booking/_status_label.html.twig' %}
{% if booking.acceptedAt is not null %} @@ -177,6 +177,23 @@ {% endif %}
+ {% if booking.draft or booking.requested %} +
+

Angebot

+

+ Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink + erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden. +

+ +
+ {% endif %} + {% if booking.received %}

Bestätigung

@@ -238,6 +255,15 @@ zurück
+ {% if not booking.confirmed and not booking.discarded %} + + {% endif %} {% if booking.confirmed %} PDF herunterladen diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index 0bd9ffa..0707254 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -2,7 +2,7 @@ {% block body %}

- Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %} + Neue Unterkunfts{% if booking.directBooking %}buchung{% else %}anfrage{% endif %}

{% if accessLink %} diff --git a/templates/groups/booking/offer.html.twig b/templates/groups/booking/offer.html.twig index 30481cc..53350e1 100644 --- a/templates/groups/booking/offer.html.twig +++ b/templates/groups/booking/offer.html.twig @@ -61,7 +61,7 @@ {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}

- {{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angefragte' }} Leistungen + {{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angebotene' }} Leistungen

    {% if booking.boardServiceLabel %} @@ -74,7 +74,7 @@
{% endif %} - {% if booking.inquiry and booking.open %} + {% if booking.open %}