Compare commits

...
5 Commits
35 changed files with 987 additions and 1993 deletions

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import { Controller } from '@hotwired/stimulus'
import Sortable from 'sortablejs'
export default class extends Controller {
static targets = [ 'list', 'item', 'handle', 'positionField', 'positionLabel', 'status' ]
static values = {
url: String,
}
connect() {
this.abortController = null
// The sortable container may be nested (e.g. a tbody) when the controller element also
// has to wrap elements that must not be dragged, like the status message.
new Sortable(this.hasListTarget ? this.listTarget : this.element, {
draggable: '[data-sortable-target="item"]',
handle: '[data-sortable-target="handle"]',
sort: true,
fallbackOnBody: true,
onEnd: e => {
this.updateOrdering()
this.persist()
}
})
}
disconnect() {
this.abortController?.abort()
}
updateOrdering() {
this.positionFieldTargets.forEach((field, index) => {
field.value = index + 1
})
this.positionLabelTargets.forEach((label, index) => {
label.innerText = index + 1
})
}
/**
* Sends the new order to the server. Only active when a url value is present, so the
* controller stays usable for plain form ordering.
*/
persist() {
if (!this.hasUrlValue || this.urlValue === '') {
return
}
// A drag while the previous request is still running makes that request obsolete.
this.abortController?.abort()
this.abortController = new AbortController()
const ids = this.itemTargets
.map(item => Number(item.dataset.sortableId))
.filter(id => Number.isInteger(id) && id > 0)
this.setStatus('speichert …')
fetch(this.urlValue, {
method: 'POST',
credentials: 'include',
signal: this.abortController.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids }),
})
.then(response => {
if (!response.ok) {
throw new Error(`Unexpected response ${response.status}`)
}
this.setStatus('Reihenfolge gespeichert')
})
.catch(error => {
if (error.name === 'AbortError') {
return
}
console.error('Failed to persist ordering', error)
this.setStatus('Speichern fehlgeschlagen')
})
}
setStatus(message) {
this.statusTargets.forEach(status => {
status.innerText = message
})
}
}
-122
View File
@@ -1,122 +0,0 @@
# Body Dimensions Transition Plan (Stakeholder Draft)
> **STATUS (verified 2026-07-15): NOT IMPLEMENTED.** `src/Form/BodyDimensionsType.php` still declares `height`, `shoeSize`, and `weight` as `IntegerType` fields, and `src/Validator/Constraints/ParticipantValidator.php` has no regex/legacy-format handling — only numeric range checks. This plan has not been acted on.
## Goal
Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model.
This plan introduces a temporary compatibility phase and keeps sunsetting manual and straightforward.
## Current Problem
- Existing bookings may contain legacy values such as `-148`, `149-157`, or `195+`.
- Integer-only form fields can fail when these legacy strings are loaded.
- Result: participant edit forms can crash before submit.
## Proposed Transition Strategy
Use **text input fields** for body dimensions during transition, with explicit server-side format validation.
### Key Decisions
- No gating by booking create date.
- One unified behavior for all bookings.
- Accept both legacy and numeric formats during transition.
- Keep manual sunsetting later (no automated migration switch).
## Functional Scope
Affected fields:
- `height`
- `weight`
- `shoeSize`
Affected components:
- `src/Form/BodyDimensionsType.php`
- `src/Validator/Constraints/ParticipantValidator.php`
- `src/Form/BookingParticipantType.php` (option passthrough already in place)
- `config/services.yaml` (ranges remain as validation/config source)
## Validation Rules During Transition
### Accepted formats
- Numeric value: `^\d+$` (e.g. `176`)
- Legacy lower/open/range tokens:
- `^-\d+$` (e.g. `-148`)
- `^\d+\+$` (e.g. `195+`)
- `^\d+\s*-\s*\d+$` (e.g. `149-157`)
### Requiredness
- Keep existing rule: body dimensions required when rentals are selected.
### Range checks
- Keep current numeric range checks based on `body_dimension_ranges`.
- Apply range checks only to plain numeric values in transition phase.
## UX/Behavior Expectations
- Existing legacy values render without form initialization errors.
- New numeric entries are accepted and validated.
- Invalid free text (e.g. `abc`, `17x`) shows clear validation errors.
- HTMX refresh behavior remains unchanged.
## Implementation Steps
1. **Form type change**
- In `BodyDimensionsType`, replace integer fields with text fields for all three body dimensions.
- Keep labels/help text; keep placeholders based on configured ranges.
2. **Validator extension**
- Add format validation for all three fields in `ParticipantValidator`.
- Keep existing required-when-rentals logic.
- Keep existing range validation for numeric values.
3. **Message tuning**
- Add one clear message for invalid format.
- Retain existing range message for numeric out-of-range values.
4. **Testing and QA**
- Validate old bookings with legacy values open/edit successfully.
- Validate numeric path (valid and invalid range).
- Validate invalid format handling.
- Validate rental-required behavior.
## Acceptance Criteria
- No 500 error when editing participants with legacy body-dimension values.
- Legacy values can be loaded and submitted in transition phase.
- Numeric values are accepted and range-validated.
- Invalid text is rejected with a user-facing validation message.
- Existing participant edit/refresh flows continue to work.
## Risks and Mitigations
- **Risk:** Free-text field semantics are less strict than integer fields.
- **Mitigation:** strict server-side regex + range checks.
- **Risk:** Inconsistent data representations during transition.
- **Mitigation:** explicit acceptance policy and manual sunset plan.
## Effort Estimate
- Implementation: 0.5 day
- Validation/message tuning: 0.25 day
- QA/manual testing: 0.5 day
- **Total:** ~1 to 1.5 days
## Manual Sunset Plan (Later)
When stakeholders approve end of transition:
1. Replace text fields with final semantic field type(s).
2. Remove legacy format acceptance from validator.
3. Keep only final numeric/range validation behavior.
4. Remove transition-specific tests and copy.
Expected cleanup effort: ~0.25 to 0.5 day.
-196
View File
@@ -1,196 +0,0 @@
# Booking Flow Reference
Last updated: 2026-07-15
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
---
## Shared Concepts
### `BookingDto`
The central data object for both flows. Holds room selections, participants, payment method, and travel reference. Lives in the HTTP session between requests. Has two modes: `MODE_CREATE` and `MODE_EDIT`.
### `BookingSessionManager`
Owns all reads and writes to session-stored `BookingDto` instances. Also manages the baseline room snapshot used for change detection in the create flow.
### `BookingCreateContextFactory`
Assembles the view model (`BookingCreateContext`) passed to create-flow templates. Handles room grouping (by-pax vs. by-room) and wires summary sidebar data.
---
## Create Flow
```
Step 1 (room selection)
→ Step 2 (participants)
→ Step 2 sub-form (per-participant, HTMX)
→ Step 3 (payment + API pre-validation)
→ Step 4 (confirmation + final submission)
→ Success
```
### Entry / Bootstrap
`AbstractBookingCreateController::loadBookingCreateDto()` is called by every step controller and delegates to `BookingSessionManager::getOrCreateBookingCreateDto()`. If no DTO exists in session, `BookingConfigurator::startFreshBooking()` loads travel data and initialises a fresh `BookingDto`.
### Step 1 — Room Selection
**Controller:** `Create/Step1Controller`
**Route:** `GET|POST /bookings/create/rooms`
1. Load or create DTO via `getOrCreateBookingCreateDto()`
2. `BookingSessionManager::getOrCreateBaselineSnapshot()` — saves the current room selection as a comparison point for change detection
3. Render `BookingCreateStep1Type` form
4. On submit:
- If room selection changed from baseline: `BookingDto::resetParticipantAssignments()` — clears stale assignments
- `BookingConfigurator::updateBookingStatusFromRoomSelection()` — may switch booking to inquiry mode if capacity constraints require it
- `BookingSessionManager::saveBookingDto()`, advance `currentStep` to 2
- `BookingSessionManager::clearBaselineSnapshot()`
- Redirect to Step 2
HTMX refresh (`/bookings/create/refresh`): processes the form without validation and returns updated room selection + summary sidebar blocks.
### Step 2 — Participants (Card Overview)
**Controller:** `Create/Step2Controller`
**Route:** `GET|POST /bookings/create/participants`
1. Load DTO from session (or redirect to Step 1 if missing)
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
4. `ParticipantDataPrefiller::shouldPrefillApplicant()` + `prefillApplicantFromUser()` — prefills participant[0] from user profile on first visit
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
8. `BookingConfigurator::applyCreateBookingStatusRules()` — re-evaluates booking status (book / inquiry / not-bookable)
9. `BookingSessionManager::saveBookingDto()`
10. On form submit (all participants valid): advance to Step 3, redirect
### Step 2 — Individual Participant Form (HTMX)
**Controller:** `Create/Step2ParticipantController`
**Routes:** `GET|POST /bookings/create/participants/{index}` and `/participants/{index}/refresh`
Each participant card lazy-loads its form via HTMX. The controller:
1. Verifies the participant slot exists
2. Refreshes availability
3. Handles dummy data fill (`ParticipantDataPrefiller::isDummyDataFillRequested()` / `fillDummyParticipant()`)
4. On submit: re-applies status rules, saves to session, redirects to card overview
5. `ParticipantFormSupport` is used for form creation options and notification draining
### Step 3 — Payment + API Pre-Validation
**Controller:** `Create/Step3Controller`
**Route:** `GET|POST /bookings/create/payment`
1. Load DTO from session
2. `BookingConfigurator::applyCreateBookingStatusRules()`
3. On submit:
- `ApiClient::createBookingInquiry()` — submits booking to API for validation without committing it
- If inquiry fails with "nicht möglich" + "Anfrage" in message: auto-switch to inquiry mode, advance to Step 4
- If inquiry fails otherwise: show error, stay on step
- Price verification: `BookingPriceCalculator::calculateGrandTotal()` is compared against the API response total (accounting for promotional/goodwill voucher discounts and API-applied group discounts). Mismatch triggers `BookingPriceMismatchAnalyzer` for detailed logging.
- On success: advance `currentStep` to 4, redirect
### Step 4 — Confirmation + Final Submission
**Controller:** `Create/Step4Controller`
**Route:** `GET|POST /bookings/create/confirmation`
1. Load DTO from session
2. Newsletter subscription check for opt-in display
3. On submit:
- `BookingConfigurator::applyCreateBookingStatusRules()`
- `ApiClient::createBooking()` — final booking submission (already validated in Step 3)
- Newsletter double-opt-in if selected
- On success: store booking number + total in flash (for analytics), clear travel data cache, `BookingSessionManager::clearBookingDto()`, redirect to success page
---
## Edit Flow
```
Entry (start)
→ Load (session or API + draft restore)
→ Participant sub-forms (HTMX, per-participant)
→ Submission
```
### Entry
**Controller:** `Edit/IndexController::start()`
**Route:** `GET /bookings/{id}/edit/start`
Clears any existing session DTO for edit mode and invalidates the API booking cache, then redirects to the edit page. This ensures a clean load from API on every intentional re-entry.
### Load
**Controller:** `Edit/IndexController::index()`
**Route:** `GET|POST /bookings/{id}/edit`
Loading is delegated entirely to `BookingEditDataLoader::loadFormData()`:
**If session DTO exists and matches the requested booking ID:**
- `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
- `TravelDataProvider::getMutabilityData()` + `patchMutability()` — applies cached mutability state to DTO
**If no session DTO (first load or after `start`):**
`initializeFromApi()`:
1. `fetchBookingData()` — `ApiClient::getBooking()` with 5-minute cache (tagged by user ID for bulk invalidation)
2. `TravelDataProvider::getTravelData()` — loads travel data (XML or snapshot)
3. `TravelDataProvider::getAvailabilityData(forceRefresh: true)` + `patchAvailabilities()` — force-refreshes availability at edit start, populates cache for subsequent loads within the session
4. `TravelDataProvider::getMutabilityData(cached: true)` + `patchMutability()` — applies which service categories are currently editable
5. `BookingDataProcessor::createBookingDtoFromBooking()` — hydrates `BookingDto` from API booking entity
6. Agency code resolved via `AgencyLoader` (used by field state conditions)
7. `BookingChangeTracker::generateFingerprint()` — sets `originalFingerprint` on DTO **before** draft is applied, so dirty detection compares against original API data
8. `BookingEditDraftManager::findDraft()` + `applyDraftToDto()` — if draft exists, merges user edits on top of fresh API data. Sets `draftWasRestored = true` if successful.
9. `BookingSessionManager::saveBookingDto()`
Back in the controller: shows "draft restored" flash if applicable, fetches booking data for surcharge/status display, renders the card overview with `BookingEditContextFactory::createOverviewContext()`.
### Individual Participant Form (HTMX)
**Controller:** `Edit/ParticipantController`
**Routes:** `GET|POST /bookings/{id}/edit/participants/{index}` and `/{index}/refresh`
Per-participant forms are lazy-loaded, matching the create flow pattern.
1. `BookingEditContextFactory::prepareBookingDto()` — refreshes availabilities on the DTO travel object
2. `ParticipantFormSupport` — form creation, notification draining
3. On submit: `BookingSessionManager::saveBookingDto()` + `BookingEditDraftManager::saveDraft()` — persists participant edits to both session and draft
### Submission
**Service:** `BookingEditSubmitter::handleSubmission()`
Called from `Edit/IndexController` on valid form submit.
1. Invalidate API cache, fetch fresh booking data
2. Force-refresh mutability data (`TravelDataProvider::getMutabilityData(forceRefresh: true)`) and patch DTO travel
3. `BookingEditSubmitGuard::reconcileImmutableCategories()` — reverts any DTO fields that have become immutable since the session was loaded. Shows flash if anything was reverted.
4. `ApiClient::updateBooking()` — submit
5. On success: invalidate cache again, `BookingSessionManager::clearBookingDto()`, `BookingEditDraftManager::deleteDraft()`
### Other Edit Routes
| Route | Action |
|-------|--------|
| `GET /bookings/{id}/edit/reload` | Shows confirmation modal |
| `POST /bookings/{id}/edit/reload` | Clears session + **deletes draft**, reloads from API |
| `GET /bookings/{id}/edit/cancel` | Clears session, **preserves draft**, redirects to booking list |
The distinction between reload and cancel is intentional: cancel keeps the draft so the user can return to their changes later; reload discards everything.
---
## Key Invariants
- `currentStep` on the DTO gates forward navigation in create mode; controllers redirect to the current step if a higher step is requested directly.
- The original fingerprint is set before draft application in edit mode. Dirty detection (`BookingChangeTracker::isDirty()`) always compares the live session DTO against the original API state, not against the draft.
- Mutability is always patched from fresh or cached data before rendering. The DTO travel object is the authoritative source of which fields are editable at render time.
- Drafts survive cancel but not reload, and are deleted on successful submission.
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260804140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add manual ordering position to additional services';
}
public function up(Schema $schema): void
{
// The default only backfills existing rows; it is dropped again so the column matches the mapping.
$this->addSql('ALTER TABLE additional_service ADD position INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE additional_service ALTER COLUMN position DROP DEFAULT');
// Seed the positions with the ordering the admin overview used before, so nothing changes
// visually until someone actually drags a row.
$this->addSql('
UPDATE additional_service s
JOIN (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY accommodation_id
ORDER BY date_from ASC, (price <> 0) ASC, label ASC
) AS rn
FROM additional_service
) o ON o.id = s.id
SET s.position = o.rn
');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE additional_service DROP position');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260804152215 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make accommodation booking contact fields nullable (filled on the edit page after creation)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking CHANGE first_name first_name VARCHAR(100) DEFAULT NULL, CHANGE last_name last_name VARCHAR(100) DEFAULT NULL, CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE group_name group_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking CHANGE group_name group_name VARCHAR(255) NOT NULL, CHANGE first_name first_name VARCHAR(100) NOT NULL, CHANGE last_name last_name VARCHAR(100) NOT NULL, CHANGE email email VARCHAR(255) NOT NULL');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260804153855 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace the accommodation booking is_inquiry flag with a four-value status';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking ADD status VARCHAR(20) DEFAULT NULL');
$this->addSql("UPDATE accommodation_booking SET status = IF(is_inquiry = 1, 'inquiry', 'booking')");
$this->addSql('ALTER TABLE accommodation_booking CHANGE status status VARCHAR(20) NOT NULL');
$this->addSql('ALTER TABLE accommodation_booking DROP is_inquiry');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking ADD is_inquiry TINYINT(1) DEFAULT NULL');
$this->addSql("UPDATE accommodation_booking SET is_inquiry = IF(status = 'inquiry', 1, 0)");
$this->addSql('ALTER TABLE accommodation_booking CHANGE is_inquiry is_inquiry TINYINT(1) NOT NULL');
$this->addSql('ALTER TABLE accommodation_booking DROP status');
}
}
@@ -12,6 +12,7 @@ use App\Service\CmsDataProvider;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -57,8 +58,8 @@ class EditController extends AbstractController
$boardServices = $accommodation->getBoardServices()->toArray();
usort($boardServices, $this->compareServices(...));
// Additional services are ordered manually, see #[ORM\OrderBy] on the association.
$additionalServices = $accommodation->getAdditionalServices()->toArray();
usort($additionalServices, $this->compareServices(...));
return $this->render('admin/accommodation/edit.html.twig', [
'accommodation' => $accommodation,
@@ -69,10 +70,59 @@ class EditController extends AbstractController
]);
}
/**
* Receives the additional service ids in their new order and renumbers the positions.
*
* Ids are only accepted when they belong to the given accommodation; services missing from the
* payload (e.g. created in another tab in the meantime) are appended in their previous order.
*/
#[Route('/admin/accommodation/{id}/additional-services/order', name: 'app_admin_accommodation_additionalservices_order', methods: ['POST'])]
public function orderAdditionalServices(Accommodation $accommodation, Request $request): Response
{
try {
$payload = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST);
}
if (!\is_array($payload) || !\is_array($payload['ids'] ?? null)) {
return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST);
}
/** @var array<int, AdditionalService> $remaining */
$remaining = [];
foreach ($accommodation->getAdditionalServices() as $additionalService) {
$remaining[$additionalService->getId()] = $additionalService;
}
$position = 0;
foreach ($payload['ids'] as $id) {
$id = \is_numeric($id) ? (int) $id : null;
if (null === $id || !isset($remaining[$id])) {
continue;
}
$remaining[$id]->setPosition(++$position);
unset($remaining[$id]);
}
// Whatever was not part of the payload keeps its relative order behind the sorted ones.
foreach ($remaining as $additionalService) {
$additionalService->setPosition(++$position);
}
$this->entityManager->flush();
return new JsonResponse(['status' => 'ok']);
}
/**
* DateFrom ascending, then free (price 0) before paid, then alphabetically by label.
*/
private function compareServices(BoardService|AdditionalService $a, BoardService|AdditionalService $b): int
private function compareServices(BoardService $a, BoardService $b): int
{
$aFree = 0 === $a->getPrice() ? 0 : 1;
$bFree = 0 === $b->getPrice() ? 0 : 1;
@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Form\Admin\Groups\AccommodationBookingCreateType;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -30,8 +31,8 @@ class CreateController extends AbstractController
{
$booking = new AccommodationBooking();
$form = $this->createForm(AccommodationBookingType::class, $booking, [
'with_accommodation' => true,
$form = $this->createForm(AccommodationBookingCreateType::class, $booking, [
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
@@ -40,20 +41,18 @@ class CreateController extends AbstractController
$this->entityManager->persist($booking);
$this->entityManager->flush();
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('success', 'Die Buchung wurde erstellt');
$this->logger->info('Created accommodation booking', [
'id' => $booking->getId(),
'groupName' => $booking->getGroupName(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
return new HxRedirectResponse(
$this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()])
);
}
return $this->render('admin/accommodation_booking/create.html.twig', [
return $this->render('admin/accommodation_booking/modal_create.html.twig', [
'form' => $form,
]);
}
@@ -7,6 +7,7 @@ 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;
@@ -75,7 +76,7 @@ class EditController extends AbstractController
'current_board_service' => $currentBoardService,
'current_additional_services' => $currentAdditionalServices,
]);
$wasInquiry = $booking->isInquiry();
$previousStatus = $booking->getStatus();
$form->handleRequest($request);
@@ -110,8 +111,11 @@ class EditController extends AbstractController
$this->entityManager->persist($booking);
$this->entityManager->flush();
if ($wasInquiry && !$booking->isInquiry()) {
$this->bookingService->issueAccessLinkForDirectBooking($booking);
// Entering Anfrage or Buchung is what the customer needs to hear about: the offer
// becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent.
$notifiableStatuses = [AccommodationBookingStatus::Inquiry, AccommodationBookingStatus::Booking];
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
$this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
}
+17
View File
@@ -44,6 +44,10 @@ class OfferController extends AbstractController
return $this->render('groups/booking/offer_unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
$this->linkSigner->authorizeSession($request, $booking);
return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]));
@@ -58,6 +62,10 @@ class OfferController extends AbstractController
return $this->render('groups/booking/offer_unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$priceBreakdown = $this->breakdownCalculator->compute($booking);
@@ -105,6 +113,15 @@ 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.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return !$booking->isDraft() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
+1 -1
View File
@@ -151,7 +151,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->isInquiry() ? 'inquiry' : 'booking');
$this->addFlash('groups_booking_result', $booking->getStatus()->value);
$this->sessionManager->clear($request);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
* @var Collection<int, AdditionalService>
*/
#[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])]
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
#[ORM\OrderBy(['position' => 'ASC', 'dateFrom' => 'ASC', 'id' => 'ASC'])]
private Collection $additionalServices;
#[ORM\Column]
+40 -14
View File
@@ -9,6 +9,7 @@ use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types;
@@ -75,8 +76,8 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $pricingVersion = null;
#[ORM\Column]
private bool $isInquiry = false;
#[ORM\Column(length: 20, enumType: AccommodationBookingStatus::class)]
private AccommodationBookingStatus $status = AccommodationBookingStatus::Draft;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $accessLinkIssuedAt = null;
@@ -84,19 +85,24 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $acceptedAt = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(groups: ['edit'])]
private ?string $groupName = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $salutation = null;
#[ORM\Column(length: 100)]
#[ORM\Column(length: 100, nullable: true)]
#[Assert\NotBlank(groups: ['edit'])]
private ?string $firstName = null;
#[ORM\Column(length: 100)]
#[ORM\Column(length: 100, nullable: true)]
#[Assert\NotBlank(groups: ['edit'])]
private ?string $lastName = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(groups: ['edit'])]
#[Assert\Email(groups: ['edit'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
@@ -330,18 +336,38 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->pricingVersion;
}
public function isInquiry(): bool
public function getStatus(): AccommodationBookingStatus
{
return $this->isInquiry;
return $this->status;
}
public function setIsInquiry(bool $isInquiry): self
public function setStatus(AccommodationBookingStatus $status): self
{
$this->isInquiry = $isInquiry;
$this->status = $status;
return $this;
}
public function isDraft(): bool
{
return AccommodationBookingStatus::Draft === $this->status;
}
public function isInquiry(): bool
{
return AccommodationBookingStatus::Inquiry === $this->status;
}
public function isBooking(): bool
{
return AccommodationBookingStatus::Booking === $this->status;
}
public function isDiscarded(): bool
{
return AccommodationBookingStatus::Discarded === $this->status;
}
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
{
return $this->accessLinkIssuedAt;
@@ -371,7 +397,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->groupName;
}
public function setGroupName(string $groupName): self
public function setGroupName(?string $groupName): self
{
$this->groupName = $groupName;
@@ -395,7 +421,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->firstName;
}
public function setFirstName(string $firstName): self
public function setFirstName(?string $firstName): self
{
$this->firstName = $firstName;
@@ -407,7 +433,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->lastName;
}
public function setLastName(string $lastName): self
public function setLastName(?string $lastName): self
{
$this->lastName = $lastName;
@@ -419,7 +445,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->email;
}
public function setEmail(string $email): self
public function setEmail(?string $email): self
{
$this->email = $email;
+18
View File
@@ -59,6 +59,12 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
#[ORM\Column]
private bool $isExclusive = false;
/**
* Manual presentation order within the accommodation, maintained by drag & drop in the admin.
*/
#[ORM\Column]
private int $position = 0;
public function __clone()
{
$this->id = null;
@@ -188,4 +194,16 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): self
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Enum\Groups;
enum AccommodationBookingStatus: string
{
case Draft = 'draft';
case Inquiry = 'inquiry';
case Booking = 'booking';
case Discarded = 'discarded';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Inquiry => 'Anfrage',
self::Booking => 'Buchung',
self::Discarded => 'Absage',
};
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
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\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Lean form for creating a booking. The available board and additional services can only be
* resolved once accommodation and date range are known, so they are offered on the edit page.
*
* @extends AbstractType<AccommodationBooking>
*/
class AccommodationBookingCreateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('accommodation', EntityType::class, [
'class' => Accommodation::class,
'choice_label' => 'name',
'label' => 'Gruppenhaus',
])
->add('dateFrom', DateType::class, [
'label' => 'Anreise',
'widget' => 'single_text',
])
->add('dateTo', DateType::class, [
'label' => 'Abreise',
'widget' => 'single_text',
])
->add('paxCount', IntegerType::class, [
'label' => 'Anzahl Personen',
])
->add('minorsCount', IntegerType::class, [
'label' => 'davon Kinder (0–3 Jahre)',
])
->add('childrenCount', IntegerType::class, [
'label' => 'davon Kinder',
])
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Inquiry,
AccommodationBookingStatus::Booking,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
]);
}
}
@@ -4,20 +4,21 @@ declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingStatus;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
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;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Range;
@@ -26,17 +27,13 @@ class AccommodationBookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if ($options['with_accommodation']) {
$builder->add('accommodation', EntityType::class, [
'class' => Accommodation::class,
'choice_label' => 'name',
'label' => 'Gruppenhaus',
]);
}
// The contact fields carry no HTML5 `required` attribute: a draft must be saveable
// while still empty. They are enforced server-side by the `edit` validation group
// as soon as the booking leaves Entwurf — see configureOptions().
$builder
->add('groupName', TextType::class, [
'label' => 'Gruppenname',
'required' => false,
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
@@ -50,12 +47,15 @@ class AccommodationBookingType extends AbstractType
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
'required' => false,
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'required' => false,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
])
->add('phone', TextType::class, [
'label' => 'Telefon',
@@ -122,9 +122,10 @@ class AccommodationBookingType extends AbstractType
}
$builder
->add('isInquiry', CheckboxType::class, [
'label' => 'Anfrage (nicht bindend)',
'required' => false,
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
->add('accommodationDiscount', IntegerType::class, [
'label' => 'Rabatt Unterkunft (%)',
@@ -158,14 +159,24 @@ class AccommodationBookingType extends AbstractType
{
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
'with_accommodation' => false,
// 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.
'validation_groups' => static function (FormInterface $form): array {
/** @var AccommodationBooking $booking */
$booking = $form->getData();
return $booking->isDraft() || $booking->isDiscarded()
? ['Default']
: ['Default', 'edit'];
},
'max_adolescent_age' => 0,
'board_services' => [],
'additional_services' => [],
'current_board_service' => null,
'current_additional_services' => [],
]);
$resolver->setAllowedTypes('with_accommodation', 'bool');
$resolver->setAllowedTypes('max_adolescent_age', 'int');
$resolver->setAllowedTypes('board_services', 'array');
$resolver->setAllowedTypes('additional_services', 'array');
@@ -88,7 +88,7 @@ final readonly class AccommodationBookingApiResponse
public function __construct(AccommodationBooking $booking, ?array $priceBreakdown)
{
$this->uuid = $booking->getUuid();
$this->status = $booking->isInquiry() ? 'inquiry' : 'booking';
$this->status = $booking->getStatus()->value;
$this->dateFrom = $booking->getDateFrom();
$this->dateTo = $booking->getDateTo();
$this->nights = $booking->getNights();
@@ -6,6 +6,7 @@ namespace App\Repository\Groups;
use App\Entity\Groups\AdditionalService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -20,4 +21,16 @@ class AdditionalServiceRepository extends ServiceEntityRepository
{
parent::__construct($registry, AdditionalService::class);
}
/**
* Additional services are ordered manually in the admin; dateFrom and id only break ties
* between records that share a position (newly created or duplicated ones).
*/
protected function applyOrdering(QueryBuilder $queryBuilder): void
{
$queryBuilder
->orderBy('s.position', 'ASC')
->addOrderBy('s.dateFrom', 'ASC')
->addOrderBy('s.id', 'ASC');
}
}
@@ -6,6 +6,7 @@ namespace App\Repository\Groups;
use App\Entity\Groups\Accommodation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @template T of object
@@ -26,16 +27,26 @@ trait FindsByAccommodationAndDateRangeTrait
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
): array {
return $this->createQueryBuilder('s')
$queryBuilder = $this->createQueryBuilder('s')
->where('s.accommodation = :accommodation')
->andWhere('s.dateFrom <= :dateTo')
->andWhere('s.dateTo >= :dateFrom')
->setParameter('accommodation', $accommodation)
->setParameter('dateFrom', $dateFrom)
->setParameter('dateTo', $dateTo)
->setParameter('dateTo', $dateTo);
$this->applyOrdering($queryBuilder);
return $queryBuilder->getQuery()->getResult();
}
/**
* Ordering applied to the query above; override to sort by something else.
*/
protected function applyOrdering(QueryBuilder $queryBuilder): void
{
$queryBuilder
->orderBy('s.dateFrom', 'ASC')
->addOrderBy('s.label', 'ASC')
->getQuery()
->getResult();
->addOrderBy('s.label', 'ASC');
}
}
+41 -19
View File
@@ -10,6 +10,7 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -156,11 +157,9 @@ class AccommodationBookingService
}
}
// Additional services keep the repository order (manual position), which also makes the
// groups appear in the order of their lowest-positioned member.
$boardServices = $this->sortServicesByPriceThenLabel($boardServices);
$ungrouped = $this->sortServicesByPriceThenLabel($ungrouped);
foreach ($grouped as $groupName => $groupServices) {
$grouped[$groupName] = $this->sortServicesByPriceThenLabel($groupServices);
}
return [
'boardServices' => $boardServices,
@@ -171,17 +170,15 @@ class AccommodationBookingService
}
/**
* Free services (price 0) first, then alphabetically; each group sorted alphabetically.
* Free services (price 0) first, then alphabetically.
*
* @template T of BoardService|AdditionalService
* @param BoardService[] $services
*
* @param T[] $services
*
* @return T[]
* @return BoardService[]
*/
private function sortServicesByPriceThenLabel(array $services): array
{
usort($services, static function (BoardService|AdditionalService $a, BoardService|AdditionalService $b): int {
usort($services, static function (BoardService $a, BoardService $b): int {
$aFree = 0 === $a->getPrice() ? 0 : 1;
$bFree = 0 === $b->getPrice() ? 0 : 1;
@@ -268,7 +265,11 @@ class AccommodationBookingService
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
$booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry);
$booking->setStatus(
$dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry
? AccommodationBookingStatus::Inquiry
: AccommodationBookingStatus::Booking
);
// Freeze board service as scalar fields (no FK)
if (null !== $dto->selectedBoardServiceId) {
@@ -416,13 +417,13 @@ class AccommodationBookingService
}
/**
* Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet.
* Sets accessLinkIssuedAt if the booking doesn't have one yet, whatever its status.
* No email side effect — the confirmation email is always sent separately via
* sendCustomerConfirmationEmail(), regardless of whether a link exists.
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
public function issueAccessLink(AccommodationBooking $booking): void
{
if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) {
if (null !== $booking->getAccessLinkIssuedAt()) {
return;
}
@@ -430,18 +431,39 @@ class AccommodationBookingService
$this->entityManager->flush();
}
/**
* Issues the access link only for a confirmed booking. Inquiries from the public flow
* deliberately get no link — the office prepares the offer first and issues the link
* when it switches the booking to Anfrage.
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
if (!$booking->isBooking()) {
return;
}
$this->issueAccessLink($booking);
}
/**
* 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.
* exists yet — the template renders differently depending on accessLink being present,
* so the subject has to follow the same distinction.
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
if (!$booking->isInquiry()) {
$subject = 'Deine Buchung ist bestätigt';
} elseif (null !== $booking->getAccessLinkIssuedAt()) {
$subject = 'Dein Angebot ist bereit';
} else {
$subject = 'Deine Anfrage ist bei uns eingegangen';
}
$this->sendBookingEmail(
$booking,
$booking->getEmail(),
$booking->isInquiry()
? 'Deine Anfrage ist bei uns eingegangen'
: 'Deine Buchung ist bestätigt',
$subject,
'email/accommodation_booking_customer.html.twig',
'Failed to send accommodation booking customer confirmation email',
);
@@ -468,7 +490,7 @@ class AccommodationBookingService
return;
}
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush();
+15 -5
View File
@@ -233,15 +233,21 @@
<hr class="my-8">
<section id="additional-services">
<section id="additional-services" {{ stimulus_controller('sortable', {
url: path('app_admin_accommodation_additionalservices_order', { id: accommodation.id })
}) }}>
<h2 class="text-xl font-bold">
Optionale Zusatzleistungen
</h2>
<p class="text-sm text-gray-500 mb-2">
Die Reihenfolge wird per Drag &amp; Drop festgelegt und gilt auch für das Buchungsformular.
</p>
<div class="data-table-wrapper mb-4">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th class="w-8"></th>
<th>
Datums-Bereich
</th>
@@ -257,9 +263,12 @@
<th></th>
</tr>
</thead>
<tbody>
<tbody {{ stimulus_target('sortable', 'list') }}>
{% for service in additionalServices %}
<tr>
<tr {{ stimulus_target('sortable', 'item') }} data-sortable-id="{{ service.id }}">
<td class="cursor-grab text-gray-400" {{ stimulus_target('sortable', 'handle') }}>
{{ icon('draggable', 'w-4 h-4') }}
</td>
<td>
{{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }}
</td>
@@ -288,7 +297,7 @@
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">
<td colspan="6" class="text-center">
Keine Daten
</td>
</tr>
@@ -297,7 +306,8 @@
</table>
</div>
</div>
<div class="flex justify-end">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500" {{ stimulus_target('sortable', 'status') }}></span>
<a href="{{ path('app_admin_additionalservice_create', { 'id': accommodation.id }) }}" class="button button--primary button--small">
neu
</a>
@@ -8,9 +8,10 @@
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
{{ form_row(form.name) }}
{{ form_row(form.calendarCode) }}
{{ form_row(form.maxAdolescentAge) }}
{{ form_row(form.currency) }}
{{ form_row(form.calendarCode) }}
{{ form_row(form.cmsCode) }}
{{ form_row(form.maxAdolescentAge) }}
</div>
<div class="flex justify-end pt-8">
<button type="submit" class="button button--primary button--small">
@@ -1,53 +0,0 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>Neue Buchung</twig:page:heading>
{% form_theme form 'forms_admin.html.twig' %}
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
<div class="lg:col-span-2">
{{ form_row(form.accommodation) }}
</div>
<h2 class="lg:col-span-2 text-base font-bold pt-2">Kontaktdaten</h2>
{{ form_row(form.groupName) }}
{{ form_row(form.salutation) }}
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
{{ form_row(form.street) }}
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Reisedaten</h2>
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
{{ form_row(form.paxCount) }}
{{ form_row(form.minorsCount) }}
{{ form_row(form.childrenCount) }}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
<div class="lg:col-span-2">
{{ form_row(form.remarks) }}
</div>
</div>
<div class="flex justify-between pt-8">
<a href="{{ path('app_admin_accommodationbooking') }}" class="button button--secondary button--small">
zurück
</a>
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -40,7 +40,7 @@
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
{{ form_row(form.status) }}
</div>
{% if booking.acceptedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
@@ -50,11 +50,7 @@
{{ booking.paxCount }}
</td>
<td>
{% if booking.isInquiry %}
Anfrage
{% else %}
Buchung
{% endif %}
{{ booking.status.label }}
</td>
<td>
{% set discounts = [] %}
@@ -93,8 +89,12 @@
</div>
</div>
<div class="flex justify-end">
<a href="{{ path('app_admin_accommodationbooking_create') }}" class="button button--primary button--small">
<button type="button"
hx-get="{{ path('app_admin_accommodationbooking_create') }}"
hx-target="body"
hx-swap="beforeend"
class="button button--primary button--small">
neu
</a>
</button>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{% block content %}
<div class="text-xl font-bold pb-4">
Neue Buchung
</div>
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
<div class="lg:col-span-2">
{{ form_row(form.accommodation) }}
</div>
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
{{ form_row(form.paxCount) }}
{{ form_row(form.minorsCount) }}
{{ form_row(form.childrenCount) }}
<div class="lg:col-span-2">
{{ form_row(form.status) }}
</div>
</div>
<div class="flex justify-end pt-8">
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -2,7 +2,7 @@
{% block content %}
<twig:page:heading>
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
{{ booking.status.label }}
{{ booking.groupName }}
</twig:page:heading>
@@ -54,7 +54,7 @@
<dd>{{ booking.childrenCount }}</dd>
{% endif %}
<dt class="font-medium">Status</dt>
<dd>{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}</dd>
<dd>{{ booking.status.label }}</dd>
{% if booking.acceptedAt is not null %}
<dt class="font-medium">Angenommen am</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\EditController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Covers the status transition side effects only — the service and form behaviour
* they build on is tested in AccommodationBookingServiceTest.
*/
class EditControllerTest extends TestCase
{
public function testTransitionToInquiryIssuesAccessLinkAndSendsCustomerEmail(): void
{
$bookingService = $this->assertNotified(AccommodationBookingStatus::Inquiry);
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Inquiry, $bookingService);
}
public function testTransitionToBookingIssuesAccessLinkAndSendsCustomerEmail(): void
{
$bookingService = $this->assertNotified(AccommodationBookingStatus::Booking);
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Booking, $bookingService);
}
public function testTransitionToDiscardedSendsNothing(): void
{
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Inquiry, AccommodationBookingStatus::Discarded, $bookingService);
}
public function testSavingWithoutStatusChangeSendsNothing(): void
{
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Booking, AccommodationBookingStatus::Booking, $bookingService);
}
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('refreshPriceSnapshot');
$bookingService
->expects(self::once())
->method('issueAccessLink')
->with(self::callback(static fn (AccommodationBooking $booking) => $expected === $booking->getStatus()));
$bookingService->expects(self::once())->method('sendCustomerConfirmationEmail');
return $bookingService;
}
private function assertNotNotified(): AccommodationBookingService
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('refreshPriceSnapshot');
$bookingService->expects(self::never())->method('issueAccessLink');
$bookingService->expects(self::never())->method('sendCustomerConfirmationEmail');
return $bookingService;
}
private function submitStatusChange(
AccommodationBookingStatus $from,
AccommodationBookingStatus $to,
AccommodationBookingService $bookingService,
): void {
$booking = new AccommodationBooking();
$booking->setStatus($from);
// The form is what moves the entity to its new status during handleRequest().
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturnCallback(static function () use ($booking, $to, $form) {
$booking->setStatus($to);
return $form;
});
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('has')->willReturn(false);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$controller = new TestableEditController(
$entityManager,
$this->createMock(LoggerInterface::class),
$this->createMock(BoardServiceRepository::class),
$this->createMock(AdditionalServiceRepository::class),
$bookingService,
$form,
);
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/edit', 'POST'));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
}
}
final class TestableEditController extends EditController
{
public function __construct(
EntityManagerInterface $entityManager,
LoggerInterface $logger,
BoardServiceRepository $boardServiceRepo,
AdditionalServiceRepository $additionalServiceRepo,
AccommodationBookingService $bookingService,
private readonly FormInterface $form,
) {
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $bookingService);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
protected function addFlash(string $type, mixed $message): void
{
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Api;
use App\Controller\Api\AccommodationBookingController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
@@ -54,7 +55,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(20);
$booking->setAdditionalServicesDiscount(30);
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
@@ -143,7 +144,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setFirstName('Tom');
$booking->setLastName('Beispiel');
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository
@@ -194,7 +195,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setFirstName('Tom');
$booking->setLastName('Beispiel');
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository
@@ -212,7 +213,7 @@ class AccommodationBookingControllerTest extends TestCase
->method('acceptBooking')
->with($booking)
->willReturnCallback(static function (AccommodationBooking $b): void {
$b->setIsInquiry(false);
$b->setStatus(AccommodationBookingStatus::Booking);
$b->setAcceptedAt(new \DateTimeImmutable());
});
@@ -223,7 +224,7 @@ class AccommodationBookingControllerTest extends TestCase
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('booking', $payload['status']);
self::assertFalse($booking->isInquiry());
self::assertSame(AccommodationBookingStatus::Booking, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
}
@@ -239,7 +240,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setFirstName('Tom');
$booking->setLastName('Beispiel');
$booking->setEmail('[email protected]');
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$booking->setAcceptedAt($acceptedAt);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Groups;
use App\Controller\Groups\OfferController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
@@ -24,6 +25,7 @@ class OfferControllerTest extends TestCase
public function testAccessAuthorizesSessionAndRedirectsToView(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
@@ -90,10 +92,65 @@ class OfferControllerTest extends TestCase
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testAccessRendersUnavailableForDraft(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Draft);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testViewRendersUnavailableForDiscardedBooking(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Discarded);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('compute');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$this->createMock(AccommodationBookingService::class),
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testViewRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
@@ -149,7 +206,7 @@ class OfferControllerTest extends TestCase
public function testConfirmGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
@@ -180,7 +237,7 @@ class OfferControllerTest extends TestCase
public function testConfirmPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -215,7 +272,7 @@ class OfferControllerTest extends TestCase
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -250,7 +307,7 @@ class OfferControllerTest extends TestCase
public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -284,7 +341,7 @@ class OfferControllerTest extends TestCase
public function testConfirmRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Groups;
use App\Controller\Groups\Step4Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
@@ -100,7 +101,7 @@ class Step4ControllerTest extends TestCase
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setIsInquiry(true);
$booking = (new AccommodationBooking())->setStatus(AccommodationBookingStatus::Inquiry);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
@@ -273,7 +274,7 @@ class Step4ControllerTest extends TestCase
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setIsInquiry(false);
$booking = (new AccommodationBooking())->setStatus(AccommodationBookingStatus::Booking);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Admin\Groups;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Admin\Groups\AccommodationBookingType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\EmailValidator;
use Symfony\Component\Validator\ConstraintValidatorFactory;
use Symfony\Component\Validator\Validation;
class AccommodationBookingTypeTest extends TestCase
{
/**
* @dataProvider statusesWithoutMandatoryFields
*/
public function testDraftAndDiscardedAreValidatedWithoutTheEditGroup(AccommodationBookingStatus $status): void
{
self::assertSame(['Default'], $this->resolveValidationGroups($status));
}
/**
* @dataProvider customerFacingStatuses
*/
public function testCustomerFacingStatusesAddTheEditGroup(AccommodationBookingStatus $status): void
{
self::assertSame(['Default', 'edit'], $this->resolveValidationGroups($status));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesWithoutMandatoryFields(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function customerFacingStatuses(): iterable
{
yield 'inquiry' => [AccommodationBookingStatus::Inquiry];
yield 'booking' => [AccommodationBookingStatus::Booking];
}
/**
* @dataProvider statusesWithoutMandatoryFields
*/
public function testEmptyContactDataPassesValidation(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$violations = $this->validate($booking, $this->resolveValidationGroups($status));
self::assertCount(0, $violations);
}
public function testPromotedBookingRequiresContactData(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$violations = $this->validate($booking, ['Default', 'edit']);
$properties = array_map(static fn ($violation) => $violation->getPropertyPath(), iterator_to_array($violations));
sort($properties);
self::assertSame(['email', 'firstName', 'groupName', 'lastName'], $properties);
}
/**
* @return string[]
*/
private function resolveValidationGroups(AccommodationBookingStatus $status): array
{
$resolver = new OptionsResolver();
(new AccommodationBookingType())->configureOptions($resolver);
$booking = new AccommodationBooking();
$booking->setStatus($status);
$form = $this->createMock(FormInterface::class);
$form->method('getData')->willReturn($booking);
return ($resolver->resolve()['validation_groups'])($form);
}
/**
* @param string[] $groups
*/
private function validate(AccommodationBooking $booking, array $groups): \Symfony\Component\Validator\ConstraintViolationListInterface
{
// The app configures email_validation_mode: html5 globally (config/packages/validator.yaml);
// the standalone builder would otherwise fall back to the deprecated "loose" mode.
$validatorFactory = new ConstraintValidatorFactory([
EmailValidator::class => new EmailValidator(Email::VALIDATION_MODE_HTML5),
]);
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->setConstraintValidatorFactory($validatorFactory)
->getValidator()
->validate($booking, null, $groups);
}
}
@@ -8,6 +8,7 @@ use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
@@ -65,7 +66,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$service->issueAccessLinkForDirectBooking($booking);
@@ -80,7 +81,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$service->issueAccessLinkForDirectBooking($booking);
@@ -95,7 +96,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($issuedAt);
@@ -104,11 +105,70 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkSetsTimestampRegardlessOfStatus(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$service->issueAccessLink($booking);
self::assertNotNull($booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkNoOpsWhenAlreadySet(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($issuedAt);
$service->issueAccessLink($booking);
self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt());
}
public function testSendCustomerConfirmationEmailUsesOfferSubjectForInquiryWithLink(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('sign')->willReturn('https://example.com/offer/signed-link');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(static fn (array $options) => 'Dein Angebot ist bereit' === $options['subject']),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator);
$service->sendCustomerConfirmationEmail($booking);
}
public function testSendCustomerConfirmationEmailAlwaysSendsWithLinkWhenIssued(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
@@ -137,7 +197,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
@@ -160,7 +220,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
@@ -180,7 +240,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$previousIssuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($previousIssuedAt);
@@ -210,7 +270,7 @@ class AccommodationBookingServiceTest extends TestCase
$entityManager->expects(self::once())->method('flush');
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setStatus(AccommodationBookingStatus::Inquiry);
$booking->setEmail('[email protected]');
$mailer = $this->createMock(Mailer::class);
@@ -228,7 +288,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->acceptBooking($booking);
self::assertFalse($booking->isInquiry());
self::assertSame(AccommodationBookingStatus::Booking, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
}
@@ -243,7 +303,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$service->acceptBooking($booking);
@@ -253,7 +313,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -270,7 +330,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(false);
$booking->setStatus(AccommodationBookingStatus::Booking);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));