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())