Statistiken: Review-Fixes vor Livegang (Fehldarstellungen + Test-Regression)

Ergebnis des Senior-Reviews über dev/2026-07-22 (Konzept 22.07., Feedback 24.07.,
Plan 19.08.). Rechnerisch war nichts zu korrigieren: Payline-Punkte und Provision
des Prognose-Blocks (N4) stimmen exakt mit der Provisions-Engine überein
(54.718,5 P. bzw. 3.772,88 EUR gegengerechnet an User 454). Behoben sind
Darstellungs- und Konsistenzfehler, die der Beraterin als falsche Zahlen
aufgefallen wären:

- "+N neu" schrumpfte rückwirkend, der Bestand daneben nicht: newAboCount() nutzte
  weiter activeAbo(), während aboStockCount() auf das Monatsende zurückrechnet.
  Beide teilen sich jetzt aboStockQuery(); die Jahres-Wachstumstabelle ebenso.
- Level-Kachel widersprach sich ("Zum nächsten Level Diamant fehlen X" über
  "Karriere-Level: Diamant"): Text auf die Monats-Qualifikation umformuliert
  (de/en/es/fr), Überschrift "Qualifikation in diesem Monat", Karriere-Level nur
  noch bei Abweichung.
- Firstlines-Kachel zählte alle direkten Partner, "Teampartner gesamt" daneben nur
  aktive (F1): Kachel nutzt jetzt isActiveConsultant(), die Detailseite listet
  weiter alle und weist beide Zahlen aus.
- Neupartnerzahlen vergangener Monate sanken auf dem Home-Dashboard, sobald
  Jahresgebühren ausliefen: neue User::newPartnerReferenceDate() (laufender Monat =
  heute, abgeschlossener Monat = Monatsende). Dabei die drei Inline-Kopien der
  Neupartner-Regel auf scopeNewPartnerIn()/isNewPartnerIn() zusammengeführt.
- Top-3-Karte ließ den Betrachter aus, obwohl "ich gehört in die Liste" in der
  Juli-Runde für alle anderen Ranglisten umgesetzt wurde: zählt jetzt mit, inkl.
  is_self und "ich"-Badge.
- Neues Profilfoto zeigte bis zu 6 h ein totes Bild: die URL steckt nicht mehr im
  Cache, sondern entsteht beim Lesen (withProfileImages()).
- Test-Regression seit der Juli-Runde: Unit/Services/BackofficeDashboardServiceTest
  fiel über die fehlende Spalte status_points (kam mit nonPaylinePoints() dazu).
- Kleinigkeiten: ProfileImageController gab die komplette Exception als
  Flash-Message aus (jetzt Log + Sprachkey), BackofficeDrilldownService formatierte
  Datumsangaben hart auf d.m.Y statt über Util::formatDateDB().

Tests: neuer HomeMonthlyStatsTest (buildMonthlyStats war ungetestet) plus Tests für
Stichtags-Zählung und Foto-Cache. Fünf Tests kodierten das alte Verhalten
(Betrachter zählt nicht mit, abgelaufener Account zählt nie) und wurden auf die
neue Semantik umgestellt, jeweils mit Gegenprobe. Suite: 861 grün (vorher 855),
89 rot — ausschließlich die in docs/2026-06-11-test-suite-vorbestehende-fehler.md
dokumentierten Altlasten.

Offen und bewusst nicht enthalten:
- Deploy-Schritt: backoffice:store-statistics-snapshots --force (vorher dumpen).
  Kein vorhandener Snapshot enthält recruiter_ranking, Mai/Juni zusätzlich weder
  non_payline_points noch die Führungskräfte-"gesamt"-Spalten; der Cron überspringt
  vorhandene Snapshots ohne --force. Achtung: die Neuberechnung ist nicht
  verlustfrei.
- TreeHtmlRendererTest/TreeCalcBotIntegrationTest: die Abo-Badges machten den
  Renderer datenbankabhängig; die Dateien waren schon vorher rot und gehören in die
  separate Test-Aufräumaufgabe.
- deleteImage bleibt ein GET-Route (gleiches Muster wie das Shop-Bild).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-08-25 09:41:53 +00:00
parent f6faad9ae7
commit cee4a2e0eb
30 changed files with 338 additions and 133 deletions

View file

@ -128,15 +128,19 @@ class HomeController extends Controller
? app(PaylineForecastService::class)->levelAndCommissionForecast($user, $month, $year)
: null;
$firstlineIds = User::firstlinesOf($user->id)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$dashboardService = app(BackofficeDashboardService::class);
$teamOverview = app(TeamAboOverviewService::class);
$paylineService = app(PaylineForecastService::class);
// Firstlines: die Neupartner-Zählung braucht alle direkten Partner (der
// Scope filtert selbst), die Kachel zeigt aber nur aktive Berater —
// sonst zählt sie anders als "Teampartner gesamt" daneben (F1).
$firstlines = User::firstlinesOf($user->id)->get();
$firstlineIds = $firstlines->pluck('id')->map(fn ($id) => (int) $id)->all();
$activeFirstlineCount = $firstlines
->filter(fn (User $member) => $dashboardService->isActiveConsultant($member))
->count();
// Anzeige-Zählung konsistent zu "Teampartner gesamt" (F1: nur aktive
// Berater) — die Payline-Mitgliedermenge selbst enthält auch inaktive,
// deren Abos weiterhin in die Abo-/Punkte-Summen der Payline zählen.
@ -159,7 +163,7 @@ class HomeController extends Controller
'payline_forecast' => $paylineForecast,
'commission' => app(LiveCommissionService::class)->commission($user, $month, $year),
'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year),
'firstline_count' => count($firstlineIds),
'firstline_count' => $activeFirstlineCount,
'team_partner_count' => count($activeTeamUserIds),
'payline_member_count' => $activePaylineMemberCount,
'payline_abo_count' => $paylineAboCount,

View file

@ -54,7 +54,9 @@ class ProfileImageController extends Controller
return redirect(route('user_edit'));
} catch (\Exception $e) {
\Session()->flash('alert-danger', 'Error: '.$e);
// Details ins Log, nicht ins UI — die Exception enthält Serverpfade.
\Log::warning('ProfileImageController: Upload fehlgeschlagen für User '.$user->id.': '.$e->getMessage());
\Session()->flash('alert-danger', __('msg.file_upload_failed'));
return redirect(route('user_edit'));
}

View file

@ -750,6 +750,9 @@ class TeamController extends Controller
return view('user.team.firstlines', [
'rows' => $rows,
// Die Dashboard-Kachel zeigt nur aktive Berater; die Liste zeigt alle
// (inaktive sind fürs Nachfassen gerade interessant) und weist beide Zahlen aus.
'activeCount' => collect($rows)->where('is_active_account', true)->count(),
'selectedMonth' => $month,
'selectedYear' => $year,
'filterMonths' => HTMLHelper::getTransMonths(),

View file

@ -132,6 +132,18 @@ class TeamAboOverviewService
* @param int[] $userIds
*/
public function aboStockCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)->count();
}
/**
* Die Bestandsabfrage hinter {@see aboStockCount()} — offen, damit die
* "+N neu"-Zählung ({@see newAboCount()}) exakt dieselbe Stichtags-Logik
* verwendet und nicht in derselben Kachel eine andere Zählweise zeigt.
*
* @param int[] $userIds
*/
private function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder
{
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
@ -140,7 +152,7 @@ class TeamAboOverviewService
->whereIn($userColumn, $userIds);
if ($endOfMonth->gte(now()->endOfMonth())) {
return $query->activeAbo()->count();
return $query->activeAbo();
}
return $query
@ -177,8 +189,7 @@ class TeamAboOverviewService
UserAboStatusHistory::ACTION_DEACTIVATED,
])->where('created_at', '>', $endOfMonth);
});
})
->count();
});
}
/**
@ -197,15 +208,14 @@ class TeamAboOverviewService
$teamUserIds = $this->dashboardService->activeTeamUserIds($user->id);
$monthNames = HTMLHelper::getTransMonths();
$ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year);
$customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year);
$months = [];
$totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0];
for ($month = 1; $month <= 12; $month++) {
$own = $ownByMonth[$month] ?? 0;
$customer = $customerByMonth[$month] ?? 0;
// Gleiche Stichtags-Zählung wie die "+N neu"-Badges, damit die
// Wachstumszahlen vergangener Monate nicht nachträglich sinken.
$own = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year);
$customer = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year);
$months[] = [
'month' => $month,
@ -227,35 +237,6 @@ class TeamAboOverviewService
];
}
/**
* Zählt neue (aktive) Abos je Monat eines Jahres anhand start_date.
* DB-agnostisch (kein MONTH()/strftime): rohe Datumswerte werden in PHP gruppiert.
*
* @param int[] $userIds
* @return array<int, int>
*/
private function newAbosByMonth(array $userIds, string $userColumn, string $isFor, int $year): array
{
$dates = UserAbo::query()
->activeAbo()
->where('is_for', $isFor)
->whereIn($userColumn, $userIds)
->whereYear('start_date', $year)
->toBase()
->pluck('start_date');
$byMonth = [];
foreach ($dates as $date) {
if (! $date) {
continue;
}
$month = (int) Carbon::parse($date)->month;
$byMonth[$month] = ($byMonth[$month] ?? 0) + 1;
}
return $byMonth;
}
/**
* Abo-Zusammenfassung eines einzelnen Team-Mitglieds für das Detail-Modal.
* Liefert null, wenn das Mitglied nicht in der Downline des Owners liegt.
@ -499,18 +480,19 @@ class TeamAboOverviewService
}
/**
* Im Monat hinzugekommene Abos — mit derselben Stichtags-Logik wie der
* Bestand: für vergangene Monate zählt, was am Monatsende bestand, nicht
* was heute noch aktiv ist. Sonst schrumpft das "+N neu"-Badge rückwirkend,
* während der Bestand daneben stehen bleibt.
*
* @param int[] $userIds
*/
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return UserAbo::query()
->activeAbo()
->whereIn($userColumn, $userIds)
->where('is_for', $isFor)
->whereBetween('start_date', [$startDate, $endDate])
return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)
->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()])
->count();
}

View file

@ -424,27 +424,11 @@ class BackofficeDashboardService
*/
private function newPartners(Collection $users, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return $users
->filter(function (User $user) use ($startDate, $endDate): bool {
if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) {
return false;
}
$activeDate = Carbon::parse($user->active_date);
return $activeDate->betweenIncluded($startDate, $endDate);
})
->filter(fn (User $user) => $user->isNewPartnerIn($month, $year))
->count();
}
private function hasActivePaymentAccount(User $user): bool
{
return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
@ -855,9 +839,6 @@ class BackofficeDashboardService
return [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
$newPartnersBySponsor = [];
foreach ($usersByLine as $entry) {
@ -867,11 +848,7 @@ class BackofficeDashboardService
$user = $entry['user'];
if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) {
continue;
}
if (! Carbon::parse($user->active_date)->betweenIncluded($startDate, $endDate)) {
if (! $user->isNewPartnerIn($month, $year)) {
continue;
}

View file

@ -131,19 +131,12 @@ class BackofficeDrilldownService
*/
private function newPartnerRows(array $userIds, int $month, int $year): array
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->whereBetween('active_date', [$startDate, $endDate])
->newPartnerIn($month, $year)
->orderBy('active_date')
->get()
->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture())
->values();
->get();
$sponsors = User::query()
->with('account')
@ -426,6 +419,6 @@ class BackofficeDrilldownService
return null;
}
return Carbon::parse($date)->format('d.m.Y');
return Carbon::parse($date)->format(\Util::formatDateDB());
}
}

View file

@ -14,9 +14,10 @@ use Illuminate\Support\Facades\Cache;
* "Die besten Ergebnisse des Monats" (N6): drei teaminterne Top-3-Ranglisten
* für das Home-Dashboard — persönliches Volumen, Neupartner, neue Abos.
*
* Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()});
* der Betrachter selbst zählt nicht mit. Ergebnis wird 6 h gecacht
* (Muster payline_context_*, {@see PaylineForecastService}).
* Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()})
* inklusive des Betrachters selbst. Ergebnis wird 6 h gecacht
* (Muster payline_context_*, {@see PaylineForecastService}); die Profilfotos
* kommen bewusst erst beim Lesen dazu ({@see withProfileImages()}).
*/
class TopResultsService
{
@ -36,9 +37,9 @@ class TopResultsService
/**
* @return array{
* top_volume: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
* top_recruiters: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
* top_new_abos: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
* top_volume: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>,
* top_recruiters: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>,
* top_new_abos: array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, profile_image_url: string|null, value: float|int}>
* }
*/
public function monthlyTop3(User $user, int $month, int $year): array
@ -46,30 +47,70 @@ class TopResultsService
$cacheKey = "top_results_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
return $this->withProfileImages($cached);
}
$teamUserIds = $this->scopedUserIds($user);
$viewerId = (int) $user->id;
$result = [
'top_volume' => $this->rankRows($this->volumeByUserId($teamUserIds, $month, $year)),
'top_recruiters' => $this->rankRows($this->newPartnerCountBySponsorId($teamUserIds, $month, $year)),
'top_new_abos' => $this->rankRows($this->newAboCountByUserId($teamUserIds, $month, $year)),
'top_volume' => $this->rankRows($this->volumeByUserId($teamUserIds, $month, $year), $viewerId),
'top_recruiters' => $this->rankRows($this->newPartnerCountBySponsorId($teamUserIds, $month, $year), $viewerId),
'top_new_abos' => $this->rankRows($this->newAboCountByUserId($teamUserIds, $month, $year), $viewerId),
];
Cache::put($cacheKey, $result, now()->addHours(self::CACHE_HOURS));
return $result;
return $this->withProfileImages($result);
}
/**
* Team-Ermittlung gekapselt für den Scope-Schalter {@see self::SCOPE}.
*
* Der Betrachter zählt mit (Kundenwunsch aus der Juli-Runde: „ich" gehört in
* die Ranglisten) — konsistent zu den Kundenabo-Ranglisten der VIP-Statistik.
*
* @return int[]
*/
private function scopedUserIds(User $viewer): array
{
return $this->dashboardService->activeTeamUserIds($viewer->id);
return array_values(array_unique(array_merge(
[(int) $viewer->id],
$this->dashboardService->activeTeamUserIds($viewer->id)
)));
}
/**
* Profilfoto-URLs erst beim Lesen ergänzen, nicht mitcachen: der Dateiname
* ändert sich bei jedem Upload, eine gecachte URL zeigte sonst bis zu 6 h
* lang ein totes Bild.
*
* @param array<string, array<int, array<string, mixed>>> $result
* @return array<string, array<int, array<string, mixed>>>
*/
private function withProfileImages(array $result): array
{
$userIds = collect($result)->flatten(1)->pluck('user_id')->unique()->values()->all();
if ($userIds === []) {
return $result;
}
// Das Konto haengt ueber users.account_id (belongsTo), nicht ueber eine
// user_id-Spalte an user_accounts — deshalb ueber die User laden.
$imageUrls = User::query()
->with('account')
->whereIn('id', $userIds)
->get()
->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]);
foreach ($result as $key => $rows) {
foreach ($rows as $index => $row) {
$result[$key][$index]['profile_image_url'] = $imageUrls->get((int) $row['user_id']);
}
}
return $result;
}
/**
@ -159,9 +200,9 @@ class TopResultsService
* hydriert, die die Top 3 noch erreichen können.
*
* @param array<int, float|int> $valuesByUserId
* @return array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
* @return array<int, array{user_id: int, first_name: string, last_name: string, is_self: bool, value: float|int}>
*/
private function rankRows(array $valuesByUserId): array
private function rankRows(array $valuesByUserId, int $viewerId): array
{
$valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0);
if ($valuesByUserId === []) {
@ -191,7 +232,7 @@ class TopResultsService
'user_id' => (int) $userId,
'first_name' => (string) $user->account->first_name,
'last_name' => (string) $user->account->last_name,
'profile_image_url' => $user->account->getProfileImageUrl(),
'is_self' => (int) $userId === $viewerId,
'value' => $value,
];
}

View file

@ -232,11 +232,23 @@ class User extends Authenticatable
->whereNull('deleted_at');
}
/**
* Stichtag der Neupartner-Prüfung: im laufenden (oder einem zukünftigen)
* Monat gilt „heute", für abgeschlossene Monate das Monatsende. Sonst würde
* die Neupartnerzahl eines vergangenen Monats rückwirkend sinken, sobald
* Jahresgebühren auslaufen — die Zahl muss aber stabil bleiben.
*/
public static function newPartnerReferenceDate(int $month, int $year): Carbon
{
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
return $endOfMonth->isFuture() ? now() : $endOfMonth;
}
/**
* Neupartner-Definition der Statistik (eine Regel für KPI, Recruiter-Ranking
* und Top-3-Karte): Mitgliedsebene gesetzt, Abrechnungskonto aktuell gültig,
* im Monat aktiv geworden. Achtung: `payment_account > now()` macht die
* Zählung rückwirkend kleiner, sobald Accounts ablaufen (bewusster Ist-Stand).
* und Top-3-Karte): Mitgliedsebene gesetzt, Abrechnungskonto zum Stichtag
* gültig ({@see newPartnerReferenceDate()}), im Monat aktiv geworden.
*
* @param Builder $query
* @return Builder
@ -248,10 +260,30 @@ class User extends Authenticatable
return $query->whereNotNull('m_level')
->whereNotNull('payment_account')
->where('payment_account', '>', now())
->where('payment_account', '>', self::newPartnerReferenceDate($month, $year))
->whereBetween('active_date', [$startDate, $endDate]);
}
/**
* PHP-seitiges Gegenstück zu {@see scopeNewPartnerIn()} für bereits geladene
* User-Collections (Linien-Auswertung, Recruiter-Ranking) — damit die
* Neupartner-Regel nur an einer Stelle definiert ist.
*/
public function isNewPartnerIn(int $month, int $year): bool
{
if ($this->m_level === null || $this->active_date === null || $this->payment_account === null) {
return false;
}
if (Carbon::parse($this->payment_account)->lte(self::newPartnerReferenceDate($month, $year))) {
return false;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
return Carbon::parse($this->active_date)->betweenIncluded($startDate, $startDate->copy()->endOfMonth());
}
public function user_pre_sponsor()
{
return $this->belongsTo('App\User', 'pre_sponsor');