mivita/app/Services/Team/TopResultsService.php
Kevin 640a05945b 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>
2026-08-25 09:41:53 +00:00

245 lines
9.1 KiB
PHP

<?php
namespace App\Services\Team;
use App\Models\UserAbo;
use App\Services\Abo\PaylineForecastService;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\SalesVolumePoints;
use App\User;
use Carbon\Carbon;
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()})
* 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
{
/**
* Sichtbarkeits-Scope der Ranglisten. 'team' = Downline des Betrachters
* (Kundenentscheid 2026-08, teamintern). 'company' (firmenweit, EIN Ranking
* für alle) ist als spätere Option vorgesehen — dafür nur
* {@see scopedUserIds()} erweitern, die Ranglisten-Logik bleibt gleich.
*/
public const SCOPE = 'team';
private const CACHE_HOURS = 6;
private const TOP_LIMIT = 3;
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{
* 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
{
$cacheKey = "top_results_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $this->withProfileImages($cached);
}
$teamUserIds = $this->scopedUserIds($user);
$viewerId = (int) $user->id;
$result = [
'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 $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 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;
}
/**
* Persönliches Monatsvolumen (Gesamtpunkte = eigene + Kundenpunkte) je
* Berater — eine gebatchte Query über user_sales_volumes.
*
* @param int[] $teamUserIds
* @return array<int, float> user_id => Punkte (> 0)
*/
private function volumeByUserId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
return SalesVolumePoints::totalPointsByUser($teamUserIds, $month, $year)
->filter(fn (float $points) => $points > 0)
->map(fn (float $points) => round($points, 2))
->all();
}
/**
* Direkt gesponserte Neupartner im Monat je Sponsor — Definition identisch
* zu {@see BackofficeDashboardService::newPartnersCount()} (m_level gesetzt,
* aktives Abrechnungskonto, active_date im Monat), Zuordnung über
* users.m_sponsor; nur Sponsoren aus dem Team.
*
* @param int[] $teamUserIds
* @return array<int, int> sponsor_user_id => Anzahl
*/
private function newPartnerCountBySponsorId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
return User::query()
->firstlinesOf($teamUserIds)
->newPartnerIn($month, $year)
->groupBy('m_sponsor')
->selectRaw('m_sponsor, COUNT(*) as value')
->pluck('value', 'm_sponsor')
->map(fn ($value) => (int) $value)
->all();
}
/**
* Neue aktive Abos im Monat je Berater: neue Kundenabos (member_id, is_for
* 'ot') plus neues Eigenabo (user_id, is_for 'me') — Kundenentscheid N6/F7b.
* Zählweise wie {@see BackofficeDashboardService::newAboCount()}.
*
* @param int[] $teamUserIds
* @return array<int, int> user_id => Anzahl
*/
private function newAboCountByUserId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
$newAboQuery = fn (string $userColumn, string $isFor) => UserAbo::query()
->activeAbo()
->whereBetween('start_date', [$startDate, $endDate])
->whereIn($userColumn, $teamUserIds)
->where('is_for', $isFor)
->groupBy($userColumn)
->selectRaw($userColumn.' as uid, COUNT(*) as value')
->pluck('value', 'uid');
$counts = [];
foreach ($newAboQuery('member_id', 'ot') as $userId => $value) {
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
}
foreach ($newAboQuery('user_id', 'me') as $userId => $value) {
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
}
return $counts;
}
/**
* Baut aus user_id=>Wert die Top-3-Zeilen (Foto/Name), sortiert nach Wert
* absteigend, bei Gleichstand nach Name. Es werden nur die Kandidaten
* 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, is_self: bool, value: float|int}>
*/
private function rankRows(array $valuesByUserId, int $viewerId): array
{
$valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0);
if ($valuesByUserId === []) {
return [];
}
arsort($valuesByUserId);
// Kandidaten in Wert-Reihenfolge hydrieren — Zeilen ohne Account werden
// übersprungen und von Nachrückern aufgefüllt; der Puffer begrenzt die
// Hydrierung bei großen Teams.
$valuesByUserId = array_slice($valuesByUserId, 0, self::TOP_LIMIT + 22, true);
$users = User::query()
->with('account')
->whereIn('id', array_keys($valuesByUserId))
->get()
->keyBy('id');
$rows = [];
foreach ($valuesByUserId as $userId => $value) {
$user = $users->get($userId);
if (! $user || ! $user->account) {
continue;
}
$rows[] = [
'user_id' => (int) $userId,
'first_name' => (string) $user->account->first_name,
'last_name' => (string) $user->account->last_name,
'is_self' => (int) $userId === $viewerId,
'value' => $value,
];
}
usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name']]
<=> [$a['value'], $b['first_name'], $b['last_name']]);
return array_slice($rows, 0, self::TOP_LIMIT);
}
}