, * top_recruiters: array, * top_new_abos: array * } */ 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>> $result * @return array>> */ 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 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 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. * * Stichtags-Logik wie bei den Abo-Kacheln des Dashboards * ({@see TeamAboOverviewService::aboStockQuery()}): für vergangene Monate * zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand) * schrumpfte die Rangliste eines abgeschlossenen Monats mit jedem beendeten * Abo nachträglich — die Top-3-Neupartner daneben sind über * {@see User::scopeNewPartnerIn()} bereits stichtagsstabil. * * @param int[] $teamUserIds * @return array 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) => $this->teamAboOverview ->aboStockQuery($teamUserIds, $userColumn, $isFor, $month, $year) ->whereBetween('start_date', [$startDate, $endDate]) ->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. * * A11: Der Namens-Tiebreak greift erst nach dem Wert — die Kandidatenmenge * darf deshalb nicht mitten durch einen Gleichstand geschnitten werden, sonst * entscheidet die Eingabereihenfolge der Abfrage statt des Namens. Deshalb * zwei Schritte vor dem Hydrieren: Kandidaten ohne Account fallen vorher weg * (sie könnten die Auswahl sonst leer laufen lassen), und die Auswahlgrenze * wird um alle Gleichstände an dieser Grenze erweitert. * * @param array $valuesByUserId * @return array */ private function rankRows(array $valuesByUserId, int $viewerId): array { $valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0); if ($valuesByUserId === []) { return []; } // Nur Ids: die Namen kommen erst für die tatsächlich nötigen Kandidaten. $withAccount = User::query() ->whereIn('id', array_keys($valuesByUserId)) ->whereHas('account') ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); $valuesByUserId = array_intersect_key($valuesByUserId, array_flip($withAccount)); if ($valuesByUserId === []) { return []; } arsort($valuesByUserId); $valuesByUserId = self::takeIncludingTies($valuesByUserId, self::TOP_LIMIT); $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, ]; } // user_id als letzte Regel: bei gleichem Wert UND gleichem Namen ist die // Reihenfolge sonst nicht reproduzierbar. usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name'], $a['user_id']] <=> [$a['value'], $b['first_name'], $b['last_name'], $b['user_id']]); return array_slice($rows, 0, self::TOP_LIMIT); } /** * Die ersten $limit Einträge einer absteigend sortierten Werteliste plus alle * weiteren, die mit dem letzten davon gleichauf liegen. Damit ist garantiert, * dass jeder Kandidat, der die Top 3 über den Namens-Tiebreak noch erreichen * kann, in der Auswahl bleibt. * * @param array $sortedValues * @return array */ private static function takeIncludingTies(array $sortedValues, int $limit): array { $taken = []; $boundaryValue = null; foreach ($sortedValues as $userId => $value) { if (count($taken) < $limit) { $taken[$userId] = $value; $boundaryValue = $value; continue; } if ($value != $boundaryValue) { break; } $taken[$userId] = $value; } return $taken; } }