feat: duplicate an accommodation booking into a new draft

addresses #869f6z44f
This commit is contained in:
fromme committed 2026-09-28 08:55:01 +02:00
1 parent 2aa98b81fe
commit 29f295abf6
9 files changed
+470

No files matched your search

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Copying is its own named action rather than a flag on the edit route the way it is for the
* catalog entities (see Admin\AdditionalService\EditController): a booking carries a lifecycle,
* so the copy has to be reset and written before anybody edits it, and the edit form is built
* from a persisted record's house and service catalog.
*
* Unlike absagen and löschen this has no status guard — the original is not touched, so a
* confirmed or discarded booking may be copied just as well as a draft.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class DuplicateController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/duplicate', name: 'app_admin_accommodationbooking_duplicate')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if ($request->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(),
]);
}
}
@@ -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;
@@ -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) {
@@ -97,6 +97,11 @@
<twig:dropdown:link url="{{ edit_url }}">
bearbeiten
</twig:dropdown:link>
{# Copying leaves the original alone, so it is offered in every
status — see DuplicateController. #}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_duplicate', { 'id': booking.id, 'r': return_url() }) }}">
duplizieren
</twig:dropdown:hxbutton>
{% if booking.confirmed %}
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
PDF herunterladen
@@ -0,0 +1,15 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}{{ booking.recordLabel }} kopieren{% endblock %}
{% block content %}
<div>
Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für
<em>{{ booking.groupName }}</em> 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.
</div>
{% endblock %}
{% block button_confirm %}kopieren{% endblock %}
@@ -291,6 +291,13 @@
{% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
hx-target="body"
hx-swap="beforeend">
duplizieren
</button>
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
bearbeiten
</a>
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\DuplicateController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around copying — the copy itself is tested in AccommodationBookingTest
* and AccommodationBookingServiceTest.
*/
class DuplicateControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->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<string, array{AccommodationBookingStatus}>
*/
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('[email protected]');
return $booking;
}
}
final class TestableDuplicateController extends DuplicateController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
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<string, mixed> $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<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -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('[email protected]', $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('[email protected]'));
$booking->setUpdatedBy(new User('[email protected]'));
$booking->setDateFrom(new \DateTimeImmutable('2026-03-01'));
$booking->setDateTo(new \DateTimeImmutable('2026-03-08'));
$booking->setPaxCount(42);
$booking->setGroupName('Skiclub Nord');
$booking->setEmail('[email protected]');
$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;
}
}
@@ -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())