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 %}
-
{{ booking.accommodation.name }}
{% form_theme form 'forms_admin.html.twig' %} @@ -39,9 +39,13 @@ {% endif %}+ 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 %}
+ 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. +
+ +