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>
245 lines
9.1 KiB
PHP
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);
|
|
}
|
|
}
|