From 019de4e705e186d1286fb77c435dff7ae5a64e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 15 Sep 2026 14:40:39 +0200 Subject: [PATCH] feat: surface stuck booking edits to customer experts --- config/services.yaml | 2 + .../StuckBookingDraftsWidgetProvider.php | 89 ++++++++++++++++++ src/Repository/BookingEditDraftRepository.php | 31 +++++++ .../StuckBookingDraftsWidgetProviderTest.php | 93 +++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/Dashboard/Widget/StuckBookingDraftsWidgetProvider.php create mode 100644 tests/Dashboard/StuckBookingDraftsWidgetProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index e9e7df0..e905dda 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -266,6 +266,7 @@ services: - '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule' # Dashboard Widgets + App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~ App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~ App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~ App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~ @@ -274,6 +275,7 @@ services: App\Dashboard\DashboardWidgetRegistry: arguments: $providers: + - '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider' - '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider' - '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider' - '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider' diff --git a/src/Dashboard/Widget/StuckBookingDraftsWidgetProvider.php b/src/Dashboard/Widget/StuckBookingDraftsWidgetProvider.php new file mode 100644 index 0000000..469973f --- /dev/null +++ b/src/Dashboard/Widget/StuckBookingDraftsWidgetProvider.php @@ -0,0 +1,89 @@ +draftRepository->findStuck(self::MIN_AGE_DAYS, self::LIMIT); + + return new DashboardWidget( + 'Festhängende Buchungsänderungen', + array_map(fn (BookingEditDraft $draft): DashboardWidgetEntry => new DashboardWidgetEntry( + $this->label($draft), + $this->urlGenerator->generate('app_admin_bookingeditdraft_show', ['id' => $draft->getId()]), + 'edit', + ), $drafts), + 'Keine festhängenden Buchungsänderungen.', + $this->urlGenerator->generate('app_admin_bookingeditdraft'), + 'Alle Buchungsentwürfe', + ); + } + + /** + * Names the booking, how long it has been stuck, and when the customer last tried. + * + * The gap between the two is what distinguishes an abandoned edit from somebody still trying + * every few days and getting nowhere. + */ + private function label(BookingEditDraft $draft): string + { + $days = $draft->getCreatedAt()->diff(new \DateTimeImmutable())->days ?? 0; + + return sprintf( + 'Vorgang %s (%s) — seit %d Tagen, zuletzt %s', + $draft->getBookingNumber() ?? $draft->getBookingId(), + (string) $draft->getUser()->getEmail(), + $days, + $draft->getUpdatedAt()->format('d.m.Y'), + ); + } +} diff --git a/src/Repository/BookingEditDraftRepository.php b/src/Repository/BookingEditDraftRepository.php index 6862041..bd706cc 100644 --- a/src/Repository/BookingEditDraftRepository.php +++ b/src/Repository/BookingEditDraftRepository.php @@ -123,4 +123,35 @@ class BookingEditDraftRepository extends ServiceEntityRepository ->getQuery() ->execute(); } + + /** + * Finds drafts whose owner has been unable to save for a while. + * + * A draft is deleted the moment an update succeeds (see BookingEditSubmitter), so its mere age + * is the signal: one that has survived for days belongs to somebody whose changes BusPro keeps + * refusing. Drafts for departed travels are excluded - nothing can be done about those, and the + * nightly cleanup removes them anyway. + * + * Ordered by the most recent attempt rather than by age: somebody who tried again yesterday is + * still stuck and still waiting, while the oldest drafts are mostly edits abandoned months ago. + * Sorting by age alone fills the list with the latter and buries the people to help. + * + * @param int $minAgeDays How long a draft must have existed to count as stuck + * + * @return BookingEditDraft[] Most recently attempted first + */ + public function findStuck(int $minAgeDays = 7, int $limit = 10): array + { + return $this->createQueryBuilder('d') + ->join('d.user', 'u') + ->addSelect('u') + ->where('d.createdAt < :cutoff') + ->andWhere('d.travelDate >= :today') + ->setParameter('cutoff', new \DateTimeImmutable(sprintf('-%d days', $minAgeDays))) + ->setParameter('today', new \DateTimeImmutable('today')) + ->orderBy('d.updatedAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } } diff --git a/tests/Dashboard/StuckBookingDraftsWidgetProviderTest.php b/tests/Dashboard/StuckBookingDraftsWidgetProviderTest.php new file mode 100644 index 0000000..298fbc8 --- /dev/null +++ b/tests/Dashboard/StuckBookingDraftsWidgetProviderTest.php @@ -0,0 +1,93 @@ +createMock(BookingEditDraftRepository::class); + $repository->expects($this->once()) + ->method('findStuck') + ->with(7, 10) + ->willReturn([]) + ; + + $provider = new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator()); + + $this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole()); + $this->assertNotNull($provider->build()); + } + + public function testSaysSoWhenNothingIsStuck(): void + { + $widget = $this->provider([])->build(); + + $this->assertNotNull($widget); + $this->assertSame([], $widget->entries); + $this->assertSame('Keine festhängenden Buchungsänderungen.', $widget->emptyText); + } + + public function testLabelNamesTheBookingTheCustomerAndHowLongItHasBeenStuck(): void + { + $widget = $this->provider([$this->draft(111564, 'kunde@example.test', 61)])->build(); + + $this->assertNotNull($widget); + $this->assertStringContainsString('Vorgang 111564', $widget->entries[0]->label); + $this->assertStringContainsString('kunde@example.test', $widget->entries[0]->label); + $this->assertStringContainsString('seit 61 Tagen', $widget->entries[0]->label); + } + + public function testEntriesLinkToTheDraft(): void + { + $widget = $this->provider([$this->draft(111564, 'kunde@example.test', 8)])->build(); + + $this->assertNotNull($widget); + $this->assertNotNull($widget->entries[0]->url); + $this->assertSame('Alle Buchungsentwürfe', $widget->actionLabel); + } + + /** + * @param BookingEditDraft[] $drafts + */ + private function provider(array $drafts): StuckBookingDraftsWidgetProvider + { + $repository = $this->createStub(BookingEditDraftRepository::class); + $repository->method('findStuck')->willReturn($drafts); + + return new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator()); + } + + private function urlGenerator(): UrlGeneratorInterface + { + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturnCallback( + static fn (string $route): string => '/'.str_replace('_', '/', substr($route, \strlen('app_'))), + ); + + return $urlGenerator; + } + + private function draft(int $bookingNumber, string $email, int $ageDays): BookingEditDraft + { + $draft = new BookingEditDraft(new User($email), 98787, new \DateTimeImmutable('+60 days'), []); + $draft->setBookingNumber($bookingNumber); + + // createdAt is stamped by the constructor and has no setter - the age is the whole point of + // this widget, so it is set directly rather than asserted away. + $createdAt = new \ReflectionProperty(BookingEditDraft::class, 'createdAt'); + $createdAt->setValue($draft, new \DateTimeImmutable(sprintf('-%d days', $ageDays))); + + return $draft; + } +}