feat: surface stuck booking edits to customer experts
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user