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:
parent
3f74f76819
commit
640a05945b
30 changed files with 338 additions and 133 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
40
app/User.php
40
app/User.php
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
77
tests/Feature/HomeMonthlyStatsTest.php
Normal file
77
tests/Feature/HomeMonthlyStatsTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue