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 3f74f76819
commit 640a05945b
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');

View file

@ -75,9 +75,9 @@ return [
'team_partner_total' => 'Teampartner gesamt',
'payline_partner_count' => 'Teampartner in der Payline',
'payline_abo_count' => 'Abos in der Payline',
'level_current' => 'Aktuelles Level',
'level_current' => 'Qualifikation in diesem Monat',
'level_career' => 'Karriere-Level',
'level_next_missing' => 'Zum nächsten Level :level fehlen noch :points Payline-Punkte.',
'level_next_missing' => 'Für die Qualifikation als :level in diesem Monat fehlen dir noch :points Payline-Punkte.',
'level_next_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.',
'level_top_reached' => 'Höchstes Level erreicht.',
'level_hint' => 'Monatliche Qualifikationsstufe aus dem Live-Lauf. Payline-Punkte = Teampunkte deiner bezahlten Ebenen inkl. deines Kundenpunkte-Überschusses.',
@ -94,6 +94,7 @@ return [
'top_results_volume' => 'Top 3 persönliches Volumen',
'top_results_recruiters' => 'Top 3 Neupartner',
'top_results_new_abos' => 'Top 3 neue Abos',
'top_results_self' => 'ich',
'top_results_empty' => 'Noch keine Ergebnisse in diesem Monat.',
'top_results_unit_points' => 'Punkte',
'top_results_unit_partners' => 'Neupartner',

View file

@ -15,6 +15,7 @@ return [
'no_change_made' => 'keine Änderung vorgenommen',
'booked_package_has_been_changed' => 'gebuchtes Paket wurde geändert.',
'cancel_membership_is_requested' => 'Mitgliedschaft beenden ist beantragt',
'file_upload_failed' => 'Die Datei konnte nicht hochgeladen werden. Bitte versuche es erneut.',
'file_uploaded' => 'Datei hochgeladen',
'file_empty' => 'Datei leer"',
'file_deleted' => 'Datei gelöscht',

View file

@ -164,6 +164,7 @@ return [
'annual_fee_group_due_2_months' => 'Fällig in ≤ 2 Monaten',
'annual_fee_group_due_3_months' => 'Fällig in ≤ 3 Monaten',
'firstlines' => 'Firstlines',
'firstlines_count_split' => ':active aktiv von :total gesamt',
'firstlines_copy' => 'Deine direkten Teampartner mit Umsatz und Kontaktdaten - sortiert nach Punkten des gewählten Monats.',
'firstlines_none' => 'Du hast noch keine direkten Teampartner.',
'firstlines_points' => 'Umsatz (Punkte)',

View file

@ -75,9 +75,9 @@ return [
'team_partner_total' => 'Team partners total',
'payline_partner_count' => 'Team partners in the payline',
'payline_abo_count' => 'Subscriptions in the payline',
'level_current' => 'Current level',
'level_current' => 'Qualification this month',
'level_career' => 'Career level',
'level_next_missing' => ':points payline points missing to reach the next level :level.',
'level_next_missing' => 'You still need :points payline points to qualify as :level this month.',
'level_next_missing_personal' => 'Additionally :points points of personal volume are missing.',
'level_top_reached' => 'Highest level reached.',
'level_hint' => 'Monthly qualification level from the live run. Payline points = team points of your paid levels including your customer points surplus.',
@ -94,6 +94,7 @@ return [
'top_results_volume' => 'Top 3 personal volume',
'top_results_recruiters' => 'Top 3 new partners',
'top_results_new_abos' => 'Top 3 new subscriptions',
'top_results_self' => 'me',
'top_results_empty' => 'No results yet this month.',
'top_results_unit_points' => 'points',
'top_results_unit_partners' => 'new partners',

View file

@ -17,6 +17,7 @@ return [
'file_deleted' => 'file deleted',
'file_empty' => '"file empty"',
'file_not_found' => 'file not found',
'file_upload_failed' => 'The file could not be uploaded. Please try again.',
'file_uploaded' => 'file uploaded',
'homeparty_delete' => 'time out party deleted',
'homeparty_guest_delete' => 'time out party guest deleted',

View file

@ -164,6 +164,7 @@ return [
'annual_fee_group_due_2_months' => 'Due in ≤ 2 months',
'annual_fee_group_due_3_months' => 'Due in ≤ 3 months',
'firstlines' => 'Firstlines',
'firstlines_count_split' => ':active active of :total total',
'firstlines_copy' => 'Your direct team partners with turnover and contact details - sorted by points of the selected month.',
'firstlines_none' => 'You do not have any direct team partners yet.',
'firstlines_points' => 'Turnover (points)',

View file

@ -75,9 +75,9 @@ return [
'team_partner_total' => 'Socios en total',
'payline_partner_count' => 'Socios en la payline',
'payline_abo_count' => 'Suscripciones en la payline',
'level_current' => 'Nivel actual',
'level_current' => 'Calificación de este mes',
'level_career' => 'Nivel de carrera',
'level_next_missing' => 'Para el siguiente nivel :level faltan :points puntos de payline.',
'level_next_missing' => 'Para calificar como :level este mes te faltan :points puntos de payline.',
'level_next_missing_personal' => 'Además faltan :points puntos de volumen personal.',
'level_top_reached' => 'Nivel más alto alcanzado.',
'level_hint' => 'Nivel de calificación mensual del cálculo en vivo. Puntos de payline = puntos de equipo de tus niveles pagados incluyendo tu excedente de puntos de clientes.',
@ -94,6 +94,7 @@ return [
'top_results_volume' => 'Top 3 volumen personal',
'top_results_recruiters' => 'Top 3 nuevos socios',
'top_results_new_abos' => 'Top 3 nuevas suscripciones',
'top_results_self' => 'yo',
'top_results_empty' => 'Aún no hay resultados este mes.',
'top_results_unit_points' => 'puntos',
'top_results_unit_partners' => 'nuevos socios',

View file

@ -18,6 +18,7 @@ return [
'file_deleted' => 'archivo eliminado',
'file_empty' => '"archivo vacío"',
'file_not_found' => 'archivo no encontrado',
'file_upload_failed' => 'No se pudo subir el archivo. Inténtalo de nuevo.',
'file_uploaded' => 'archivo subido',
'homeparty_delete' => 'fiesta de tiempo de espera eliminada',
'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado',

View file

@ -164,6 +164,7 @@ return [
'annual_fee_group_due_2_months' => 'Vence en ≤ 2 meses',
'annual_fee_group_due_3_months' => 'Vence en ≤ 3 meses',
'firstlines' => 'Firstlines',
'firstlines_count_split' => ':active activos de :total en total',
'firstlines_copy' => 'Tus socios directos con volumen y datos de contacto - ordenados por puntos del mes seleccionado.',
'firstlines_none' => 'Aún no tienes socios directos.',
'firstlines_points' => 'Volumen (puntos)',

View file

@ -75,9 +75,9 @@ return [
'team_partner_total' => 'Partenaires au total',
'payline_partner_count' => 'Partenaires dans la payline',
'payline_abo_count' => 'Abonnements dans la payline',
'level_current' => 'Niveau actuel',
'level_current' => 'Qualification de ce mois',
'level_career' => 'Niveau de carrière',
'level_next_missing' => 'Pour le niveau suivant :level, il manque encore :points points de payline.',
'level_next_missing' => 'Pour te qualifier comme :level ce mois-ci, il te manque encore :points points de payline.',
'level_next_missing_personal' => 'Il manque en plus :points points de volume personnel.',
'level_top_reached' => 'Niveau le plus élevé atteint.',
'level_hint' => 'Niveau de qualification mensuel issu du calcul en direct. Points de payline = points d’équipe de tes niveaux payés, y compris ton excédent de points clients.',
@ -94,6 +94,7 @@ return [
'top_results_volume' => 'Top 3 volume personnel',
'top_results_recruiters' => 'Top 3 nouveaux partenaires',
'top_results_new_abos' => 'Top 3 nouveaux abonnements',
'top_results_self' => 'moi',
'top_results_empty' => 'Pas encore de résultats ce mois-ci.',
'top_results_unit_points' => 'points',
'top_results_unit_partners' => 'nouveaux partenaires',

View file

@ -15,6 +15,7 @@ return [
'no_change_made' => 'aucune modification effectuée',
'booked_package_has_been_changed' => 'le package réservé a été modifié.',
'cancel_membership_is_requested' => 'La résiliation de l’adhésion a été demandée',
'file_upload_failed' => 'Le fichier n’a pas pu être téléversé. Merci de réessayer.',
'file_uploaded' => 'Fichier téléversé',
'file_empty' => 'Fichier vide"',
'file_deleted' => 'Fichier supprimé',

View file

@ -143,6 +143,7 @@ return [
'annual_fee_group_due_2_months' => 'Échéance dans ≤ 2 mois',
'annual_fee_group_due_3_months' => 'Échéance dans ≤ 3 mois',
'firstlines' => 'Firstlines',
'firstlines_count_split' => ':active actifs sur :total au total',
'firstlines_copy' => 'Tes partenaires directs avec chiffre d’affaires et coordonnées - triés par points du mois sélectionné.',
'firstlines_none' => 'Tu n’as pas encore de partenaires directs.',
'firstlines_points' => 'Chiffre d’affaires (points)',

View file

@ -214,7 +214,8 @@
@else
<div class="text-success">{{ __('home.level_top_reached') }}</div>
@endif
@if ($level['career_name'] !== '')
{{-- Karriere-Level nur als Zusatz, wenn es von der Monats-Quali abweicht --}}
@if ($level['career_name'] !== '' && $level['career_name'] !== $level['name'])
<div class="text-muted small">{{ __('home.level_career') }}: {{ $level['career_name'] }}</div>
@endif
</div>

View file

@ -41,7 +41,11 @@
</div>
<div class="ml-3">
<div class="font-weight-bold">
{{ $row['first_name'] }} {{ $row['last_name'] }}</div>
{{ $row['first_name'] }} {{ $row['last_name'] }}
@if (!empty($row['is_self']))
<span class="badge badge-pill badge-info ml-1">{{ __('home.top_results_self') }}</span>
@endif
</div>
<div class="text-muted small">
{{ number_format((float) $row['value'], $column['decimals'], ',', '.') }}
{{ $column['unit'] }}</div>

View file

@ -9,7 +9,8 @@
<div class="card-header">
<div class="row align-items-center">
<div class="col-md-8">
<span class="font-weight-bold">{{ __('team.firstlines') }} ({{ count($rows) }})</span>
<span class="font-weight-bold">{{ __('team.firstlines') }}
({{ __('team.firstlines_count_split', ['active' => $activeCount, 'total' => count($rows)]) }})</span>
<div class="text-muted small">{{ __('team.firstlines_copy') }}</div>
</div>
<div class="col-md-4 mt-2 mt-md-0">

View file

@ -134,7 +134,8 @@ it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', functi
$root = boMakeUser();
$newInJune = boMakeUser($root->id, '2026-06-15');
$newInMay = boMakeUser($root->id, '2026-05-15');
$expiredAccount = boMakeUser($root->id, '2026-06-20', now()->subDay());
// Account schon vor dem Monatsende abgelaufen -> zaehlt fuer Juni nicht
$expiredAccount = boMakeUser($root->id, '2026-06-20', '2026-06-25');
$service = app(BackofficeDashboardService::class);
$teamIds = $service->teamUserIds($root->id);
@ -143,6 +144,20 @@ it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', functi
->and($service->newPartnersCount($teamIds, 5, 2026))->toBe(1);
});
it('haelt die Neupartnerzahl vergangener Monate stabil, wenn Accounts spaeter ablaufen', function (): void {
$root = boMakeUser();
// Im Juni aktiv geworden, Jahresgebuehr erst danach ausgelaufen: die Juni-Zahl
// darf dadurch nicht nachtraeglich sinken.
boMakeUser($root->id, '2026-06-15', now()->subDay());
$service = app(BackofficeDashboardService::class);
$teamIds = $service->teamUserIds($root->id);
expect($service->newPartnersCount($teamIds, 6, 2026))->toBe(1)
// Im laufenden Monat gilt weiterhin "Account heute gueltig".
->and($service->newPartnersCount($teamIds, (int) now()->month, (int) now()->year))->toBe(0);
});
it('rendert die kombinierte Monatsstatistik-Card mit Abo- und Neupartner-Kennzahlen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);

View file

@ -445,9 +445,9 @@ it('rankt Recruiter des Monats nach gesponserten Neupartnern inklusive ich', fun
bslMakeUser($anna->id, null, '2026-05-12'); // Mai, zaehlt nicht
bslMakeUser($root->id); // Januar-Bestand, zaehlt nicht
// abgelaufener Account: zaehlt nicht als Neupartner
// Account war schon vor dem Monatsende abgelaufen: zaehlt nicht als Neupartner
$expired = bslMakeUser($root->id, null, '2026-06-20');
$expired->forceFill(['payment_account' => now()->subDay()])->save();
$expired->forceFill(['payment_account' => '2026-06-25'])->save();
$ranking = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'];

View file

@ -0,0 +1,77 @@
<?php
use App\Http\Controllers\HomeController;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-08-15'));
Cache::flush();
});
afterEach(function () {
Carbon::setTestNow();
});
function hmsMakeUser(?int $sponsorId = null, ?string $paymentAccount = 'future'): User
{
return User::forceCreate([
'email' => 'hms-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'active_date' => '2026-01-10',
'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount,
]);
}
function hmsMonthlyStats(User $user): array
{
Request::merge(['stats_month' => 8, 'stats_year' => 2026]);
$method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats');
$method->setAccessible(true);
return $method->invoke(new HomeController, $user);
}
it('zaehlt in der Firstlines-Kachel nur aktive Berater, konsistent zu "Teampartner gesamt"', function (): void {
$root = hmsMakeUser();
hmsMakeUser($root->id);
hmsMakeUser($root->id);
// Abgelaufenes, aber gesetztes Abrechnungskonto: zaehlt wie in der
// "Berater"-Spalte weiterhin als Berater (BackofficeDashboardService::isActiveConsultant).
hmsMakeUser($root->id, now()->subMonth()->toDateString());
// Gar kein Abrechnungskonto: kein Berater
hmsMakeUser($root->id, null);
$stats = hmsMonthlyStats($root);
// Beide Kacheln stehen im selben Cluster und muessen dieselbe Basis nutzen.
expect($stats['firstline_count'])->toBe(3)
->and($stats['team_partner_count'])->toBe(3);
});
it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kachel-Menge', function (): void {
$root = hmsMakeUser();
// Im laufenden Monat aktiv geworden, Konto gueltig -> Neupartner
hmsMakeUser($root->id)->forceFill(['active_date' => '2026-08-05'])->save();
// Im laufenden Monat aktiv geworden, Konto abgelaufen -> kein Neupartner,
// aber weiterhin Berater in der Firstlines-Kachel
hmsMakeUser($root->id, now()->subDay()->toDateString())
->forceFill(['active_date' => '2026-08-06'])->save();
// Ohne Abrechnungskonto: weder Neupartner noch Berater
hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07'])->save();
$stats = hmsMonthlyStats($root);
expect($stats['direct_new_partners'])->toBe(1)
->and($stats['firstline_count'])->toBe(2);
});

View file

@ -252,6 +252,35 @@ it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zu
Carbon\Carbon::setTestNow();
});
it('zaehlt "+N neu" mit derselben Stichtags-Logik wie den Bestand', function (): void {
Carbon\Carbon::setTestNow('2026-08-15');
$root = makeUser();
$anna = makeUser($root->id);
// Im Juni gestartet, erst im Juli gekuendigt: gehoert in den Juni-Bestand
// UND in die Juni-Zugaenge - frueher fiel es aus "+N neu" heraus, weil es
// heute nicht mehr aktiv ist.
makeAbo($anna->id, $anna->id, 'ot', '2026-06-20')
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']);
// Im Juni gestartet und noch im Juni gekuendigt: zaehlt in keiner Juni-Zahl.
makeAbo($anna->id, $anna->id, 'ot', '2026-06-02')
->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-06-20']);
$svc = app(TeamAboOverviewService::class);
$juni = $svc->teamKpis($root, 6, 2026);
expect($juni['team_customer_abos'])->toBe(1)
->and($juni['team_customer_abos_new'])->toBe(1);
// Wachstumstabelle nutzt dieselbe Zaehlung
$growth = collect($svc->monthlyGrowth($root, 2026)['months'])->firstWhere('month', 6);
expect($growth['customer_new'])->toBe(1);
Carbon\Carbon::setTestNow();
});
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
$root = makeUser();
$anna = makeUser($root->id);

View file

@ -39,6 +39,11 @@ it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere a
$ben = tflMakeUser($root->id, ['first_name' => 'Ben', 'last_name' => 'Beta']);
tflMakeUser($anna->id); // Ebene 2: keine Firstline
// Abgelaufenes Abrechnungskonto: bleibt in der Liste (Nachfassen), zaehlt
// aber nicht als aktiver Berater - die Dashboard-Kachel zeigt nur diese.
$inaktiv = tflMakeUser($root->id, ['first_name' => 'Ina', 'last_name' => 'Inaktiv']);
$inaktiv->forceFill(['payment_account' => now()->subMonth()])->save();
// Eigene (Status 1) + Kundenpunkte (Status 2) zaehlen zusammen
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 100, 'status' => 1]);
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 50, 'status' => 2]);
@ -52,7 +57,8 @@ it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere a
$view = app(TeamController::class)->firstlines();
$rows = $view->getData()['rows'];
expect($rows)->toHaveCount(2)
expect($rows)->toHaveCount(3)
->and($view->getData()['activeCount'])->toBe(2) // Kachel-Zahl: nur aktive Berater
->and($rows[0]['user_id'])->toBe($ben->id) // 400 Punkte zuerst
->and($rows[0]['total_points'])->toBe(400.0)
->and($rows[1]['user_id'])->toBe($anna->id)

View file

@ -83,7 +83,7 @@ function trsTop3(User $viewer): array
return app(TopResultsService::class)->monthlyTop3($viewer, 8, 2026);
}
it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Betrachter und Fremde', function (): void {
it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, mit Betrachter, ohne Fremde', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
$ben = trsMakeUser($root->id, 'Ben', 'Beta');
@ -94,15 +94,16 @@ it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Be
trsAddVolume($anna->id, 999, 3); // pending zaehlt nicht
trsAddVolume($ben->id, 40, 1);
trsAddVolume($ben->id, 70, 1, 7); // anderer Monat zaehlt nicht
trsAddVolume($root->id, 500, 1); // Betrachter zaehlt nicht mit
trsAddVolume($root->id, 500, 1); // Betrachter zaehlt mit ("ich")
trsAddVolume($fremd->id, 800, 1); // nicht im Team
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(2)
->and($result['top_volume'][0])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0])
->and($result['top_volume'][0]['profile_image_url'])->toBeNull()
->and($result['top_volume'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]);
expect($result['top_volume'])->toHaveCount(3)
->and($result['top_volume'][0])->toMatchArray(['user_id' => $root->id, 'value' => 500.0, 'is_self' => true])
->and($result['top_volume'][1])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0, 'is_self' => false])
->and($result['top_volume'][1]['profile_image_url'])->toBeNull()
->and($result['top_volume'][2])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]);
});
it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void {
@ -134,13 +135,15 @@ it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): vo
trsMakeUser($sponsor->id, 'Alt', 'Juli', '2026-07-10'); // anderer Monat
trsMakeUser($sponsor->id, 'Neu', 'Inaktiv', '2026-08-12', null); // kein aktives Abrechnungskonto
trsMakeUser($other->id, 'Neu', 'Drei', '2026-08-05');
trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt nicht
trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt mit
$result = trsTop3($root);
expect($result['top_recruiters'])->toHaveCount(2)
->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2])
->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]);
// Gleichstand 1:1 zwischen Olaf und Root -> nach Vorname sortiert
expect($result['top_recruiters'])->toHaveCount(3)
->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2, 'is_self' => false])
->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1])
->and($result['top_recruiters'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]);
});
it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void {
@ -156,14 +159,16 @@ it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void
trsMakeAbo($anna->id, 'me', '2026-08-10', true, 4); // gekuendigt
trsMakeAbo($anna->id, 'ot', '2026-08-11', false); // inaktiv
trsMakeAbo($ben->id, 'me', '2026-08-15');
trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt nicht mit
trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt mit
trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team
$result = trsTop3($root);
expect($result['top_new_abos'])->toHaveCount(2)
->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3])
->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]);
// Gleichstand 1:1 zwischen Ben und Root -> nach Vorname sortiert
expect($result['top_new_abos'])->toHaveCount(3)
->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3, 'is_self' => false])
->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1])
->and($result['top_new_abos'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]);
});
it('cacht das Ergebnis fuer 6 Stunden', function (): void {
@ -180,6 +185,24 @@ it('cacht das Ergebnis fuer 6 Stunden', function (): void {
->and($second)->toBe($first);
});
it('zeigt ein neues Profilfoto sofort, trotz gecachter Rangliste', function (): void {
Storage::fake('public');
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
trsAddVolume($anna->id, 100, 1);
$service = app(TopResultsService::class);
expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])->toBeNull();
// Foto wird hochgeladen, nachdem die Rangliste bereits im Cache liegt
Storage::disk('public')->put('images/profile/anna.jpg', 'x');
$anna->account->forceFill(['profile_image' => 'anna.jpg'])->save();
expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])
->toContain('images/profile/anna.jpg');
});
it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);

View file

@ -134,6 +134,9 @@ beforeEach(function () {
$table->decimal('total_net', 13, 2)->nullable();
$table->unsignedTinyInteger('status')->nullable();
$table->unsignedTinyInteger('status_turnover')->nullable();
// Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von
// SalesVolumePoints::nonPaylinePoints().
$table->unsignedTinyInteger('status_points')->nullable();
$table->decimal('month_KP_points', 13, 2)->nullable();
$table->decimal('month_shop_points', 13, 2)->nullable();
$table->decimal('month_total_net', 13, 2)->nullable();