feat: surface stuck booking edits to customer experts

This commit is contained in:
2026-09-15 15:13:47 +02:00
parent a1d1fdde14
commit 019de4e705
4 changed files with 215 additions and 0 deletions
+2
View File
@@ -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'
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Dashboard\Widget;
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
use App\Entity\BookingEditDraft;
use App\Model\DashboardWidget;
use App\Model\DashboardWidgetEntry;
use App\Repository\BookingEditDraftRepository;
use App\Security\Role;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Customers whose changes cannot be saved.
*
* A draft is deleted as soon as an update succeeds, so one that has been around for days belongs to
* somebody BusPro keeps refusing — and nothing else tells anyone. One booking accumulated
* thirty-seven failed attempts across two months before it was noticed by accident; the customer
* had entered seventy-nine participants, none of which ever reached BusPro. The point of this card
* is that the next one gets noticed in the first week instead.
*
* Each entry links to the draft, since the age alone does not say what is going wrong.
*/
class StuckBookingDraftsWidgetProvider implements DashboardWidgetProviderInterface
{
/**
* How long a draft must have survived to be worth reporting.
*
* Short enough to catch a customer inside their first week of trying, long enough that an edit
* somebody merely abandoned over a weekend does not fill the card.
*/
private const MIN_AGE_DAYS = 7;
private const LIMIT = 10;
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
public function getRequiredRole(): string
{
return Role::CUSTOMER_EXPERT;
}
public function getPriority(): int
{
return 95;
}
public function build(): ?DashboardWidget
{
$drafts = $this->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'),
);
}
}
@@ -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();
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Dashboard;
use App\Dashboard\Widget\StuckBookingDraftsWidgetProvider;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Repository\BookingEditDraftRepository;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class StuckBookingDraftsWidgetProviderTest extends TestCase
{
public function testAsksForDraftsOlderThanAWeekAndRequiresCustomerExpert(): void
{
$repository = $this->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, '[email protected]', 61)])->build();
$this->assertNotNull($widget);
$this->assertStringContainsString('Vorgang 111564', $widget->entries[0]->label);
$this->assertStringContainsString('[email protected]', $widget->entries[0]->label);
$this->assertStringContainsString('seit 61 Tagen', $widget->entries[0]->label);
}
public function testEntriesLinkToTheDraft(): void
{
$widget = $this->provider([$this->draft(111564, '[email protected]', 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;
}
}