diff --git a/src/Controller/Admin/AccommodationBooking/DuplicateController.php b/src/Controller/Admin/AccommodationBooking/DuplicateController.php
new file mode 100644
index 00000000..9f29d141
--- /dev/null
+++ b/src/Controller/Admin/AccommodationBooking/DuplicateController.php
@@ -0,0 +1,70 @@
+isMethod(Request::METHOD_POST)) {
+ if (!$this->isCsrfTokenValid('duplicate_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
+ throw $this->createAccessDeniedException('Invalid CSRF token.');
+ }
+
+ $copy = $this->bookingService->duplicate($booking);
+
+ $this->addFlash('success', 'Die Kopie wurde als neuer Entwurf angelegt.');
+
+ $this->logger->info('Duplicated accommodation booking', [
+ 'id' => $booking->getId(),
+ 'copyId' => $copy->getId(),
+ 'groupName' => $copy->getGroupName(),
+ ]);
+
+ // Straight into the copy's edit form — the copy exists to be changed, and that is
+ // the only reason it was made. The return url is forwarded still encoded, the way
+ // return_url() handed it over, so "zurück" still leads to the list this started from.
+ $parameters = ['id' => $copy->getId()];
+ $returnUrl = $request->query->getString('r');
+ if ('' !== $returnUrl) {
+ $parameters['r'] = $returnUrl;
+ }
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', $parameters));
+ }
+
+ return $this->render('admin/accommodation_booking/modal_duplicate.html.twig', [
+ 'booking' => $booking,
+ 'csrf_token_id' => 'duplicate_accommodation_booking_'.$booking->getId(),
+ ]);
+ }
+}
diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php
index c3f98a55..dfac1e10 100644
--- a/src/Entity/Groups/AccommodationBooking.php
+++ b/src/Entity/Groups/AccommodationBooking.php
@@ -200,6 +200,34 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
$this->uuid = Uuid::v4()->toRfc4122();
}
+ /**
+ * A copy is a new record, not a second view of the original: it gets its own identity and
+ * its own access link token — sharing that token would put two records behind one customer
+ * link — and every mark the lifecycle left is wiped, so the copy starts as an Entwurf that
+ * has never been in front of a customer. The origin is rewritten too: however the original
+ * came about, the copy comes about as an offer the office prepared, which is what keeps the
+ * field truthful.
+ *
+ * The price snapshot, the service snapshots and all four discounts are deliberately carried
+ * over untouched. A copy is made to vary an offer that was already quoted, so it has to show
+ * the same numbers as the record it came from rather than a re-quote at today's catalog
+ * prices; saving it through the edit form recomputes it.
+ */
+ public function __clone()
+ {
+ $this->id = null;
+ $this->uuid = Uuid::v4()->toRfc4122();
+ $this->status = AccommodationBookingStatus::Draft;
+ $this->origin = AccommodationBookingOrigin::Offer;
+ $this->accessLinkIssuedAt = null;
+ $this->acceptedAt = null;
+ $this->confirmedAt = null;
+ $this->createdAt = null;
+ $this->updatedAt = null;
+ $this->createdBy = null;
+ $this->updatedBy = null;
+ }
+
public function getId(): ?int
{
return $this->id;
diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php
index 5291980e..540b4ac7 100644
--- a/src/Service/AccommodationBookingService.php
+++ b/src/Service/AccommodationBookingService.php
@@ -576,6 +576,33 @@ class AccommodationBookingService
* @throws ImplausibleBookingPriceException if the new house prices the stay implausibly high;
* the booking is left at its old house
*/
+ /**
+ * Copies a booking into a fresh Entwurf, so the office can build a variation of an offer
+ * instead of keying the whole thing in again. Everything the customer was quoted comes
+ * along verbatim — house, dates, pax, the board and additional service snapshots, the
+ * discounts and the stored price breakdown — while AccommodationBooking::__clone() resets
+ * the identity and the lifecycle.
+ *
+ * Allowed from any status, including Bestätigt and Absage: nothing on the original is
+ * touched, and "same group, next season" is exactly the case this exists for. The price is
+ * deliberately not recomputed, see __clone().
+ */
+ public function duplicate(AccommodationBooking $booking): AccommodationBooking
+ {
+ $copy = clone $booking;
+
+ // Marks the copy where the office looks for it — the list is sorted and scanned by
+ // group name, and two identical rows are otherwise impossible to tell apart.
+ if (null !== $copy->getGroupName()) {
+ $copy->setGroupName($copy->getGroupName().' (Kopie)');
+ }
+
+ $this->entityManager->persist($copy);
+ $this->entityManager->flush();
+
+ return $copy;
+ }
+
public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void
{
if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) {
diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig
index 88dd9ed4..a361b8e4 100644
--- a/templates/admin/accommodation_booking/index.html.twig
+++ b/templates/admin/accommodation_booking/index.html.twig
@@ -97,6 +97,11 @@
bearbeiten
+ {# Copying leaves the original alone, so it is offered in every
+ status — see DuplicateController. #}
+
+ duplizieren
+
{% if booking.confirmed %}
PDF herunterladen
diff --git a/templates/admin/accommodation_booking/modal_duplicate.html.twig b/templates/admin/accommodation_booking/modal_duplicate.html.twig
new file mode 100644
index 00000000..52bc3330
--- /dev/null
+++ b/templates/admin/accommodation_booking/modal_duplicate.html.twig
@@ -0,0 +1,15 @@
+{% extends 'htmx_confirmation_modal.html.twig' %}
+
+{% block title %}{{ booking.recordLabel }} kopieren{% endblock %}
+
+{% block content %}
+
+ Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für
+ {{ booking.groupName }} kopieren?
+ Die Kopie übernimmt Gruppenhaus, Zeitraum, Personen, Leistungen, Rabatte und den
+ berechneten Preis unverändert und wird als neuer Entwurf angelegt. Das Original bleibt
+ unverändert, und die Kopie bekommt einen eigenen Zugangslink.
+
+{% endblock %}
+
+{% block button_confirm %}kopieren{% endblock %}
diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig
index d297af52..b45218d5 100644
--- a/templates/admin/accommodation_booking/show.html.twig
+++ b/templates/admin/accommodation_booking/show.html.twig
@@ -291,6 +291,13 @@
{% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %}
+
bearbeiten
diff --git a/tests/Controller/Admin/AccommodationBooking/DuplicateControllerTest.php b/tests/Controller/Admin/AccommodationBooking/DuplicateControllerTest.php
new file mode 100644
index 00000000..040bfee0
--- /dev/null
+++ b/tests/Controller/Admin/AccommodationBooking/DuplicateControllerTest.php
@@ -0,0 +1,153 @@
+createMock(AccommodationBookingService::class);
+ $bookingService->expects(self::never())->method('duplicate');
+
+ $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
+
+ $response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate'));
+
+ self::assertSame(Response::HTTP_OK, $response->getStatusCode());
+ self::assertSame('admin/accommodation_booking/modal_duplicate.html.twig', $controller->renderedView);
+ }
+
+ /**
+ * Copying does not touch the original, so unlike absagen and löschen it is offered in
+ * every status — "same group, next season" starts from a confirmed booking.
+ */
+ #[DataProvider('everyStatus')]
+ public function testABookingMayBeCopiedInAnyStatus(AccommodationBookingStatus $status): void
+ {
+ $booking = $this->openBooking();
+ $booking->setStatus($status);
+
+ $bookingService = $this->createMock(AccommodationBookingService::class);
+ $bookingService->expects(self::once())
+ ->method('duplicate')
+ ->with($booking)
+ ->willReturn(new AccommodationBooking());
+
+ $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
+
+ $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
+
+ self::assertTrue($response->headers->has('HX-Redirect'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function everyStatus(): iterable
+ {
+ foreach (AccommodationBookingStatus::cases() as $status) {
+ yield $status->value => [$status];
+ }
+ }
+
+ public function testTheBrowserIsSentToTheCopyAndKeepsTheReturnUrl(): void
+ {
+ $bookingService = $this->createStub(AccommodationBookingService::class);
+ $bookingService->method('duplicate')->willReturn(new AccommodationBooking());
+
+ $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
+
+ $response = $controller->index(
+ $this->openBooking(),
+ Request::create('/admin/accommodation-booking/1/duplicate?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', 'POST'),
+ );
+
+ $location = (string) $response->headers->get('HX-Redirect');
+
+ self::assertStringContainsString('app_admin_accommodationbooking_edit', $location);
+ self::assertStringContainsString('r=', $location);
+ }
+
+ public function testPostWithAnInvalidTokenIsDenied(): void
+ {
+ $bookingService = $this->createMock(AccommodationBookingService::class);
+ $bookingService->expects(self::never())->method('duplicate');
+
+ $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class), tokenValid: false);
+
+ $this->expectException(AccessDeniedException::class);
+
+ $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
+ }
+
+ private function openBooking(): AccommodationBooking
+ {
+ $booking = new AccommodationBooking();
+ $booking->setStatus(AccommodationBookingStatus::Open);
+ $booking->setGroupName('Schulklasse 7b');
+ $booking->setEmail('customer@example.com');
+
+ return $booking;
+ }
+}
+
+final class TestableDuplicateController extends DuplicateController
+{
+ public ?string $renderedView = null;
+
+ /** @var list */
+ public array $flashes = [];
+
+ public function __construct(
+ AccommodationBookingService $bookingService,
+ LoggerInterface $logger,
+ private readonly bool $tokenValid = true,
+ ) {
+ parent::__construct($bookingService, $logger);
+ }
+
+ protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
+ {
+ return $this->tokenValid;
+ }
+
+ /**
+ * @param array $parameters
+ */
+ protected function render(string $view, array $parameters = [], ?Response $response = null): Response
+ {
+ $this->renderedView = $view;
+
+ return new Response();
+ }
+
+ protected function addFlash(string $type, mixed $message): void
+ {
+ $this->flashes[] = ['type' => $type, 'message' => $message];
+ }
+
+ /**
+ * @param array $parameters
+ */
+ protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
+ {
+ return '/'.$route.'?'.http_build_query($parameters);
+ }
+}
diff --git a/tests/Entity/Groups/AccommodationBookingTest.php b/tests/Entity/Groups/AccommodationBookingTest.php
index 0b9d1139..b9f77e5b 100644
--- a/tests/Entity/Groups/AccommodationBookingTest.php
+++ b/tests/Entity/Groups/AccommodationBookingTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Entity\Groups;
use App\Entity\Groups\AccommodationBooking;
+use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
@@ -131,4 +132,117 @@ class AccommodationBookingTest extends TestCase
self::assertSame('Buchung', $booking->recordLabel(), $origin->value);
}
}
+
+ public function testACopyGetsItsOwnIdentityAndAccessLinkToken(): void
+ {
+ // Two records behind one uuid would mean two records behind one customer access link.
+ $booking = $this->confirmedBooking();
+
+ $copy = clone $booking;
+
+ self::assertNull($copy->getId());
+ self::assertNotSame($booking->getUuid(), $copy->getUuid());
+ }
+
+ public function testACopyStartsAsAFreshOfficeDraftThatNobodyHasSeen(): void
+ {
+ $booking = $this->confirmedBooking();
+
+ $copy = clone $booking;
+
+ self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
+ self::assertSame(AccommodationBookingOrigin::Offer, $copy->getOrigin());
+ self::assertNull($copy->getAccessLinkIssuedAt());
+ self::assertNull($copy->getAcceptedAt());
+ self::assertNull($copy->getConfirmedAt());
+ self::assertNull($copy->getUpdatedAt());
+ self::assertNull($copy->getCreatedBy());
+ self::assertNull($copy->getUpdatedBy());
+ self::assertSame('Angebot', $copy->recordLabel());
+ }
+
+ public function testACopyCarriesThePricingAndTheDiscountsVerbatim(): void
+ {
+ // The copy is a variation of an offer that was already quoted, so it shows the same
+ // numbers rather than a re-quote at today's catalog prices.
+ $booking = $this->confirmedBooking();
+
+ $copy = clone $booking;
+
+ self::assertSame($booking->getPriceBreakdown(), $copy->getPriceBreakdown());
+ self::assertSame($booking->getTotalPrice(), $copy->getTotalPrice());
+ self::assertSame($booking->getPricingCurrency(), $copy->getPricingCurrency());
+ self::assertSame($booking->getPricingVersion(), $copy->getPricingVersion());
+ self::assertSame(10, $copy->getAccommodationDiscount());
+ self::assertSame(5, $copy->getBoardServiceDiscount());
+ self::assertSame(20, $copy->getAdditionalServicesDiscount());
+ self::assertSame(2500, $copy->getTotalDiscountAmount());
+ self::assertSame('Treuerabatt', $copy->getTotalDiscountLabel());
+ }
+
+ public function testACopyKeepsWhatTheOfficeWouldOtherwiseKeyInAgain(): void
+ {
+ $booking = $this->confirmedBooking();
+
+ $copy = clone $booking;
+
+ self::assertSame($booking->getDateFrom(), $copy->getDateFrom());
+ self::assertSame($booking->getDateTo(), $copy->getDateTo());
+ self::assertSame(42, $copy->getPaxCount());
+ self::assertSame('Vollpension', $copy->getBoardServiceLabel());
+ self::assertSame(1900, $copy->getBoardServicePrice());
+ self::assertSame(7, $copy->getBoardServiceOriginalId());
+ self::assertSame($booking->getAdditionalServices(), $copy->getAdditionalServices());
+ self::assertSame('Skiclub Nord', $copy->getGroupName());
+ self::assertSame('erika@example.com', $copy->getEmail());
+ }
+
+ public function testCloningLeavesTheOriginalAlone(): void
+ {
+ $booking = $this->confirmedBooking();
+
+ clone $booking;
+
+ self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
+ self::assertNotNull($booking->getAcceptedAt());
+ self::assertNotNull($booking->getConfirmedAt());
+ self::assertSame('Skiclub Nord', $booking->getGroupName());
+ }
+
+ /**
+ * A record that has been all the way through the lifecycle, so a copy of it has the most
+ * to reset.
+ */
+ private function confirmedBooking(): AccommodationBooking
+ {
+ $now = new \DateTimeImmutable('2026-01-15 10:00:00');
+
+ $booking = new AccommodationBooking();
+ $booking->setStatus(AccommodationBookingStatus::Confirmed);
+ $booking->setOrigin(AccommodationBookingOrigin::Direct);
+ $booking->setAccessLinkIssuedAt($now);
+ $booking->setAcceptedAt($now);
+ $booking->setConfirmedAt($now);
+ $booking->setCreatedAt($now);
+ $booking->setUpdatedAt($now);
+ $booking->setCreatedBy(new User('office@example.com'));
+ $booking->setUpdatedBy(new User('office@example.com'));
+ $booking->setDateFrom(new \DateTimeImmutable('2026-03-01'));
+ $booking->setDateTo(new \DateTimeImmutable('2026-03-08'));
+ $booking->setPaxCount(42);
+ $booking->setGroupName('Skiclub Nord');
+ $booking->setEmail('erika@example.com');
+ $booking->setBoardServiceLabel('Vollpension');
+ $booking->setBoardServicePrice(1900);
+ $booking->setBoardServiceOriginalId(7);
+ $booking->addAdditionalServiceSnapshot('Skipass', 12000, 'per_person', 3);
+ $booking->setAccommodationDiscount(10);
+ $booking->setBoardServiceDiscount(5);
+ $booking->setAdditionalServicesDiscount(20);
+ $booking->setTotalDiscountAmount(2500);
+ $booking->setTotalDiscountLabel('Treuerabatt');
+ $booking->setPriceSnapshot(['total' => 500000], 480000, 'EUR', 1);
+
+ return $booking;
+ }
}
diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php
index 6f452138..c9e093c8 100644
--- a/tests/Service/AccommodationBookingServiceTest.php
+++ b/tests/Service/AccommodationBookingServiceTest.php
@@ -1121,6 +1121,57 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
}
+ public function testDuplicatePersistsACopyAndLeavesTheOriginalAlone(): void
+ {
+ $booking = (new AccommodationBooking())
+ ->setGroupName('Skiclub Nord')
+ ->setPaxCount(30);
+ $booking->setStatus(AccommodationBookingStatus::Confirmed);
+
+ $persisted = null;
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::once())
+ ->method('persist')
+ ->willReturnCallback(function (object $entity) use (&$persisted): void { $persisted = $entity; });
+ $entityManager->expects(self::once())->method('flush');
+
+ $service = $this->createServiceWithAccommodation(entityManager: $entityManager);
+
+ $copy = $service->duplicate($booking);
+
+ self::assertSame($persisted, $copy);
+ self::assertNotSame($booking, $copy);
+ self::assertSame(30, $copy->getPaxCount());
+ self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
+ self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
+ self::assertSame('Skiclub Nord', $booking->getGroupName());
+ }
+
+ public function testDuplicateMarksTheCopyByName(): void
+ {
+ $booking = (new AccommodationBooking())->setGroupName('Skiclub Nord');
+
+ $copy = $this->createServiceWithAccommodation()->duplicate($booking);
+
+ self::assertSame('Skiclub Nord (Kopie)', $copy->getGroupName());
+ }
+
+ public function testDuplicateNeverRecomputesThePrice(): void
+ {
+ // The copy has to show what the original was quoted at, not today's catalog prices.
+ $booking = new AccommodationBooking();
+ $booking->setPriceSnapshot(['total' => 500000], 480000, 'CHF', 1);
+
+ $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
+ $breakdownCalculator->expects(self::never())->method('computeCurrent');
+
+ $copy = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator)->duplicate($booking);
+
+ self::assertSame(['total' => 500000], $copy->getPriceBreakdown());
+ self::assertSame(480000, $copy->getTotalPrice());
+ self::assertSame('CHF', $copy->getPricingCurrency());
+ }
+
private function createBookingWithContactData(): AccommodationBooking
{
return (new AccommodationBooking())