mivita/app/Services/Backoffice/BackofficeDrilldownService.php
Kevin 03416b0afe Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit
paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der
Backfill nimmt das Aktivierungsdatum nur noch ohne jede
Registrierungsbestellung (offene Rechnung bleibt leer).
F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt
genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel
(Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste
uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig.
F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit;
eine Ausfuehrung loescht auch den paused_by_*-Grund.
F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits
committeten Test nachgezogen.
F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false).
Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig
verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal.

Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 08:59:45 +00:00

612 lines
25 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDrilldownService
{
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{metric: string, metric_label: string, only_new: bool, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false, string $tree = BackofficeDashboardService::TREE_COMMISSION): array
{
$tree = BackofficeDashboardService::normalizeTree($tree);
$metricLabels = $this->dashboardService->metricLabels();
if (! array_key_exists($metric, $metricLabels)) {
abort(404);
}
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
// Abgeschlossener Monat: Mitglieder und Abos kommen aus dem eingefrorenen
// Snapshot, damit die Liste zu der Zahl passt, aus der sie aufgerufen wurde.
// Ohne das rechnet die Liste mit der heutigen Struktur — ein spaeter
// hinzugekommener oder verschobener Teampartner taucht dann in einem
// laengst abgerechneten Monat auf (oder verschwindet daraus).
$frozen = $this->frozenLines($viewer, $month, $year, $tree);
$userIds = $frozen !== null
? $this->frozenUserIds($frozen, $line)
: $this->dashboardService->lineUserIds($viewer->id, $line, $month, $year, $tree);
if ($metric === 'shop_1000' && $line === 0) {
$userIds[] = (int) $viewer->id;
}
$frozenAboIds = $frozen !== null
? $this->frozenAboIds($frozen, $line, $metric, $onlyNew)
: null;
$rows = match ($metric) {
'consultants' => $this->consultantRows($userIds),
'new_partners' => $this->newPartnerRows($userIds, $month, $year, $frozen !== null ? $this->frozenNewPartnerIds($frozen, $line) : null, $frozen !== null),
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
default => [],
};
if ($metric === 'shop_1000') {
$rows = $this->markViewerRow($rows, (int) $viewer->id);
}
return [
'metric' => $metric,
'metric_label' => $metricLabels[$metric].($onlyNew ? ' – nur neue' : ''),
'only_new' => $onlyNew,
'line' => $line,
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
'month' => $month,
'year' => $year,
'tree' => $tree,
'tree_label' => BackofficeDashboardService::treeLabels()[$tree] ?? $tree,
'rows' => $rows,
'summary' => $this->summary($rows),
];
}
/**
* Kennzeichnet die eigene Zeile des Betrachters (der VIP zählt beim
* 1000-Punkte-Shop selbst mit und soll in der Liste erkennbar sein).
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function markViewerRow(array $rows, int $viewerId): array
{
return array_map(function (array $row) use ($viewerId): array {
if ((int) ($row['user_id'] ?? 0) === $viewerId) {
$row['name'] .= ' (ich)';
}
return $row;
}, $rows);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, abo_count: int, points: float, customer_points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
*/
private function summary(array $rows): array
{
return [
'count' => count($rows),
'abo_count' => (int) collect($rows)->sum('abo_count'),
'points' => (float) collect($rows)->sum('points'),
'customer_points' => (float) collect($rows)->sum('customer_points'),
'own_points' => (float) collect($rows)->sum('own_points'),
'external_points' => (float) collect($rows)->sum('external_points'),
'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'),
'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'),
'customer_other_points' => (float) collect($rows)->sum('customer_other_points'),
'total_points' => (float) collect($rows)->sum('total_points'),
'storno_floor_points' => (float) collect($rows)->sum('storno_floor_points'),
'deliveries' => (int) collect($rows)->sum('deliveries'),
];
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function consultantRows(array $userIds): array
{
return User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->orderBy('id')
->get()
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'is_account_active' => Carbon::parse($user->payment_account)->isFuture(),
'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen',
'active_date' => $this->formatDate($user->active_date),
'active_date_sort' => $this->sortDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
'payment_account_sort' => $this->sortDate($user->payment_account),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function newPartnerRows(array $userIds, int $month, int $year, ?array $frozenIds = null, bool $frozen = false): array
{
// Eingefrorener Monat: exakt die im Snapshot gezaehlten Partner. Aeltere
// Snapshots ohne gespeicherte IDs wurden mit der Regel "Registrierungsmonat"
// gebaut — die Liste rechnet dann mit derselben Regel, sonst zeigt die
// Kachel 1 und die Liste 0 (Audit 18.09., F2).
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $frozenIds ?? $userIds)
->when($frozenIds === null && $frozen, fn (Builder $query) => $query->newPartnerInByActiveDate($month, $year))
->when($frozenIds === null && ! $frozen, fn (Builder $query) => $query->newPartnerIn($month, $year))
->orderBy('partner_paid_at')
->orderBy('active_date')
->get();
$sponsors = User::query()
->with('account')
->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique())
->get()
->keyBy('id');
$monthlyPoints = $this->monthlyPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
return $partners
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
// Telefon mit Mobil-Fallback, gleiche Regel wie auf der
// Firstlines-Seite (Kundenwunsch 2026-09-07: zum Nachfassen).
'phone' => trim(($user->account?->phone ?? '') !== '' ? (string) $user->account->phone : (string) ($user->account?->mobil ?? '')),
'career_level' => $this->careerLevel($user),
'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-',
'customer_points' => (float) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0),
'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0),
// Massgeblich fuer die Zugehoerigkeit zum Monat (User::scopeNewPartnerIn()).
'paid_at' => $this->formatDate($user->partner_paid_at),
'paid_at_sort' => $this->sortDate($user->partner_paid_at),
'active_date' => $this->formatDate($user->active_date),
'active_date_sort' => $this->sortDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
'payment_account_sort' => $this->sortDate($user->payment_account),
])
->values()
->all();
}
/**
* Externe Kundenpunkte (Shop + Abo) sowie Gesamtpunkte (eigene + Kundenpunkte,
* inkl. Starterpaket Status 1) je User im gewählten Monat — tagesaktuell
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
*
* @param int[] $userIds
* @return Collection<int, array{external_points: float, total_points: float}>
*/
private function monthlyPointsByUser(array $userIds, int $month, int $year): Collection
{
if ($userIds === []) {
return collect();
}
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_id')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->get()
->keyBy('user_id')
->map(fn ($row) => [
'external_points' => (float) $row->external_points,
'total_points' => (float) $row->total_points,
]);
}
/**
* Die eingefrorenen Linienzeilen eines abgeschlossenen Monats, oder null, wenn
* der Monat noch laeuft bzw. kein Snapshot (oder ein aelterer ohne `abo_ids`)
* vorliegt. Aeltere Payloads fallen bewusst auf die Live-Auswertung zurueck,
* statt eine Genauigkeit vorzutaeuschen, die sie nicht haben.
*
* @return array<int, array<string, mixed>>|null
*/
private function frozenLines(User $viewer, int $month, int $year, string $tree): ?array
{
$snapshot = BackofficeStatisticsSnapshot::query()
->where('user_id', $viewer->id)
->where('month', $month)
->where('year', $year)
->where('tree', $tree)
->first();
if (! $snapshot) {
return null;
}
$payload = $snapshot->payload;
if ((int) ($payload['payload_version'] ?? 1) < BackofficeDashboardService::PAYLOAD_VERSION) {
return null;
}
return is_array($payload['lines'] ?? null) ? $payload['lines'] : null;
}
/**
* Mitglieder einer Linie zum Monatsabschluss; Linie 0 = das ganze Team.
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]
*/
private function frozenUserIds(array $frozen, int $line): array
{
$ids = [];
foreach ($frozen as $key => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $key) !== $line) {
continue;
}
foreach ($row['user_ids'] ?? [] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* Neupartner einer Linie zum Monatsabschluss; null, wenn der Snapshot die IDs
* noch nicht kennt (gebaut vor dem 18.09.2026) — dann gilt die damalige Regel.
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]|null
*/
private function frozenNewPartnerIds(array $frozen, int $line): ?array
{
$ids = [];
foreach ($frozen as $index => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
continue;
}
if (! array_key_exists('new_partner_ids', $row)) {
return null;
}
foreach ($row['new_partner_ids'] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der
* Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung).
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]|null
*/
private function frozenAboIds(array $frozen, int $line, string $metric, bool $onlyNew): ?array
{
if (! in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
return null;
}
$key = $metric.($onlyNew ? '_new' : '');
$ids = [];
foreach ($frozen as $index => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
continue;
}
foreach ($row['abo_ids'][$key] ?? [] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('user_id', $userIds)->where('is_for', 'me'))
->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
->orderBy('next_date')
->get()
->map(fn (UserAbo $abo) => [
'type' => 'abo',
'abo_id' => $abo->id,
'user_id' => $abo->user_id,
'name' => $abo->user ? $this->userName($abo->user) : '#'.$abo->user_id,
'email' => $abo->user?->email,
'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-',
'points' => $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'start_date_sort' => $this->sortDate($abo->getRawOriginal('start_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
'next_date_sort' => $this->sortDate($abo->getRawOriginal('next_date')),
'deliveries' => $abo->getCountOrders(),
'status' => $abo->status,
'status_label' => $abo->getStatusType(),
'status_badge' => $abo->getStatusFormated(),
'status_reason' => $this->aboStatusReason($abo),
])
->values()
->all();
}
/**
* Teamkundenabos pro Teampartner (member_id) gruppiert: je Teampartner die
* Anzahl der Kundenabos, die Gesamt-Volumenpunkte sowie die einzelnen Abos
* mit Punkten je Abo. Kundennamen bleiben aus Datenschutzgründen außen vor.
*
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('member_id', $userIds)->where('is_for', 'ot'))
->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
->orderBy('member_id')
->orderBy('next_date')
->get()
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id)
->map(function ($abos) use ($month, $year) {
$consultant = $abos->first()->member;
$memberId = (int) $abos->first()->member_id;
$aboItems = $abos->map(fn (UserAbo $abo) => [
'abo_id' => $abo->id,
'points' => (float) $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
])->values()->all();
return [
'type' => 'customer_abo_group',
'consultant_id' => $memberId,
'name' => $consultant ? $this->userName($consultant) : '#'.$memberId,
'email' => $consultant?->email,
'career_level' => $consultant ? $this->careerLevel($consultant) : '-',
'abo_count' => $abos->count(),
'points' => (float) $abos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
'abos' => $aboItems,
];
})
->sortByDesc('points')
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function pointsRows(array $userIds, int $month, int $year, string $metric): array
{
$rows = UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_sales_volumes.user_id')
->selectRaw(SalesVolumePoints::ownPoints().' as own_points')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points')
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->selectRaw(SalesVolumePoints::commissionPoints().' as commission_points')
->groupBy('user_sales_volumes.user_id');
// `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in
// der Uebersichtssumme (dort mit 0 gewertet, siehe Knotenregel) und muss
// deshalb auch in der Detailliste auftauchen — sonst ergibt die Detailsumme
// etwas anderes als die verlinkte Kachel. Nur die echte Qualifikationsliste
// behaelt ihre Schwelle.
if ($metric === 'own_points') {
$rows->havingRaw(SalesVolumePoints::ownPoints().' <> 0');
}
if ($metric === 'external_points') {
$rows->havingRaw(SalesVolumePoints::externalPoints().' <> 0');
}
if ($metric === 'customer_abo_points') {
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' <> 0');
}
if ($metric === 'customer_single_order_points') {
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' <> 0');
}
if ($metric === 'customer_other_points') {
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' <> 0');
}
if ($metric === 'total_points') {
$rows->havingRaw(SalesVolumePoints::commissionPoints().' <> 0');
}
if ($metric === 'shop_1000') {
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
}
// „Gesamtpunkte" der Liste = Provisionspunkte mit Knotenregel (negativer Saldo
// = 0), damit die Listensumme der Kachel „Umsatz (Punkte)" entspricht. Die
// 1000-Punkte-Liste behaelt ihre eigene Definition (Eigen- + Kundenpunkte).
$salesRows = $rows->orderByDesc($metric === 'shop_1000' ? 'total_points' : 'commission_points')->get();
$users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id');
return $salesRows
->map(fn (UserSalesVolume $row) => [
'type' => 'points',
'user_id' => $row->user_id,
'name' => $users->has($row->user_id) ? $this->userName($users->get($row->user_id)) : '#'.$row->user_id,
'email' => $users->get($row->user_id)?->email,
'career_level' => $users->has($row->user_id) ? $this->careerLevel($users->get($row->user_id)) : '-',
'own_points' => (float) $row->own_points,
'external_points' => (float) $row->external_points,
'customer_abo_points' => (float) $row->customer_abo_points,
'customer_single_order_points' => (float) $row->customer_single_order_points,
'customer_other_points' => (float) $row->customer_other_points,
'total_points' => $metric === 'shop_1000' ? (float) $row->total_points : max(0.0, (float) $row->commission_points),
'storno_floor_points' => $metric === 'shop_1000' ? 0.0 : max(0.0, -(float) $row->commission_points),
])
->values()
->all();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6]);
}
/**
* Grundabfrage der Abo-Listen: im laufenden Monat der heutige Aktivbestand, in
* einem abgeschlossenen Monat exakt die eingefrorenen Abos.
*
* `withTrashed()` ist dort nicht optional — ein Abo, das der Monat zu Recht
* enthaelt, kann inzwischen gekuendigt oder geloescht sein. Ohne den Zusatz
* waere die Liste kuerzer als die Zahl, aus der sie aufgerufen wurde.
*
* @param int[]|null $frozenAboIds
*/
private function aboScope(?array $frozenAboIds): Builder
{
if ($frozenAboIds === null) {
return $this->activeAboQuery();
}
return UserAbo::query()->withTrashed()->whereIn('id', $frozenAboIds);
}
/**
* Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie
* die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}).
*/
private function whereNewInMonth(Builder $query, int $month, int $year): Builder
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
return $query->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]);
}
private function userName(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
return $name !== '' ? $name : ($user->email ?: '#'.$user->id);
}
private function careerLevel(User $user): string
{
return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-');
}
private function aboStatusReason(UserAbo $abo): ?string
{
if ((int) $abo->status === 2) {
return null;
}
$transaction = $abo->user_abo_orders
->sortByDesc('created_at')
->pluck('shopping_order')
->filter()
->map(fn ($order) => $order->getLastShoppingPaymentTransaction())
->filter()
->first();
if (! $transaction) {
return null;
}
$message = $transaction->errormessage ?: $transaction->customermessage;
if (! $message) {
return null;
}
return $transaction->errorcode ? '['.$transaction->errorcode.'] '.$message : $message;
}
private function isAboNewInMonth(UserAbo $abo, int $month, int $year): bool
{
$startDate = $abo->getRawOriginal('start_date');
if (! $startDate) {
return false;
}
$date = Carbon::parse($startDate);
return (int) $date->month === $month && (int) $date->year === $year;
}
private function formatDate(mixed $date): ?string
{
if ($date === null || $date === '') {
return null;
}
return Carbon::parse($date)->format(\Util::formatDateDB());
}
/**
* Unformatierter Sortierwert zu einem Datum (A14). Die Anzeige über
* {@see formatDate()} ist lokalisiert — bei `en` liefert
* {@see \Util::formatDateDB()} `Y-m-d` statt `d.m.Y` —, deshalb bekommt die
* Tabelle den Sortierschlüssel getrennt vom Anzeigetext.
*/
private function sortDate(mixed $date): ?string
{
if ($date === null || $date === '') {
return null;
}
return Carbon::parse($date)->format('Y-m-d');
}
}