4, ]; /** * Mindestwert für einen Platz: bei den Recruitern teilen sich sonst oft * zehn und mehr Partner mit je einem Neupartner einen Platz. */ private const MIN_VALUES = [ 'recruiters' => 2, ]; private const CACHE_MINUTES = 15; public function __construct(private TeamAboOverviewService $teamAboOverview) {} /** * Anerkennungen eines abgeschlossenen Monats: eingefroren, falls vorhanden, * sonst vorläufig berechnet. * * @return array{month: int, year: int, is_final: bool, frozen_at: Carbon|null, freeze_date: Carbon, categories: array>}>>} */ public function recognitions(int $month, int $year): array { $snapshot = RecognitionSnapshot::query() ->where('year', $year) ->where('month', $month) ->first(); $categories = $snapshot ? $snapshot->payload['categories'] : Cache::remember( self::cacheKey('podium', $month, $year), now()->addMinutes(self::CACHE_MINUTES), fn () => $this->calculate($month, $year)['categories'] ); return [ 'month' => $month, 'year' => $year, 'is_final' => $snapshot !== null, 'frozen_at' => $snapshot?->frozen_at, 'freeze_date' => self::freezeDate($month, $year), 'categories' => $this->withProfileImages($categories, 'members'), ]; } /** * Live-Ranglisten (Top {@see self::LIVE_LIMIT}) je Kategorie, 15 Minuten gecacht. * * @return array{month: int, year: int, calculated_at: Carbon, categories: array>>} */ public function liveRankings(int $month, int $year): array { $cached = Cache::remember( self::cacheKey('live', $month, $year), now()->addMinutes(self::CACHE_MINUTES), fn () => [ 'calculated_at' => now()->toIso8601String(), 'categories' => $this->calculate($month, $year)['rankings'], ] ); return [ 'month' => $month, 'year' => $year, 'calculated_at' => Carbon::parse($cached['calculated_at']), 'categories' => $this->withProfileImages($cached['categories']), ]; } /** * Friert einen abgeschlossenen Monat ein. Ein vorhandener Snapshot bleibt * ohne $force unangetastet. */ public function freeze(int $month, int $year, bool $force = false): bool { $exists = RecognitionSnapshot::query()->where('year', $year)->where('month', $month)->exists(); if ($exists && ! $force) { return false; } RecognitionSnapshot::query()->updateOrCreate( ['year' => $year, 'month' => $month], ['payload' => $this->calculate($month, $year), 'frozen_at' => now()] ); Cache::forget(self::cacheKey('podium', $month, $year)); return true; } /** * Ab diesem Zeitpunkt gilt ein Monat als festgeschrieben. */ public static function freezeDate(int $month, int $year): Carbon { return Carbon::create($year, $month, 1)->addMonthNoOverflow()->day(self::FREEZE_DAY)->startOfDay(); } public static function isFreezable(int $month, int $year): bool { return now()->gte(self::freezeDate($month, $year)); } /** * Abgeschlossene Monate von heute rückwärts bis zum ersten Anerkennungsmonat. * * @return array */ public static function closedMonths(): array { $months = []; $cursor = now()->startOfMonth()->subMonthNoOverflow(); $first = Carbon::create(self::FIRST_YEAR, self::FIRST_MONTH, 1); while ($cursor->gte($first)) { $months[] = ['month' => (int) $cursor->month, 'year' => (int) $cursor->year]; $cursor->subMonthNoOverflow(); } return $months; } /** * @return array{categories: array>>, rankings: array>>} */ private function calculate(int $month, int $year): array { $userIds = $this->eligibleUserIds(); $result = ['categories' => [], 'rankings' => []]; foreach (self::CATEGORIES as $category) { $entries = $this->sortedEntries($this->categoryValues($category, $userIds, $month, $year)); $result['categories'][$category] = self::podium( $entries, self::PODIUM_PLACES[$category] ?? self::DEFAULT_PODIUM_PLACES, self::MIN_VALUES[$category] ?? 0 ); $result['rankings'][$category] = self::ranking($entries, self::LIVE_LIMIT); } return $result; } /** * Alle Berater außer den nie aufgeführten Konten (config mivita.recognition_excluded_user_ids). * * @return int[] */ private function eligibleUserIds(): array { return User::query() ->whereNotNull('m_level') ->whereNull('deleted_at') ->whereNotIn('id', config('mivita.recognition_excluded_user_ids', [])) ->whereHas('account') ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); } /** * @param int[] $userIds * @return array user_id => Wert (> 0) */ private function categoryValues(string $category, array $userIds, int $month, int $year): array { if ($userIds === []) { return []; } $values = match ($category) { 'recruiters' => User::query() ->firstlinesOf($userIds) ->newPartnerIn($month, $year) ->groupBy('m_sponsor') ->selectRaw('m_sponsor, COUNT(*) as value') ->pluck('value', 'm_sponsor') ->map(fn ($value) => (int) $value), 'new_customer_abos' => $this->teamAboOverview ->aboStockQuery($userIds, 'member_id', 'ot', $month, $year) ->whereBetween('start_date', [ Carbon::create($year, $month, 1)->startOfMonth(), Carbon::create($year, $month, 1)->endOfMonth(), ]) ->groupBy('member_id') ->selectRaw('member_id, COUNT(*) as value') ->pluck('value', 'member_id') ->map(fn ($value) => (int) $value), 'customer_abos_total' => $this->teamAboOverview ->aboStockQuery($userIds, 'member_id', 'ot', $month, $year) ->groupBy('member_id') ->selectRaw('member_id, COUNT(*) as value') ->pluck('value', 'member_id') ->map(fn ($value) => (int) $value), 'customer_abo_points' => UserSalesVolume::query() ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') ->whereIn('user_sales_volumes.user_id', $userIds) ->where('user_sales_volumes.month', $month) ->where('user_sales_volumes.year', $year) ->groupBy('user_sales_volumes.user_id') ->selectRaw('user_sales_volumes.user_id, '.SalesVolumePoints::customerAboPoints().' as value') ->pluck('value', 'user_id') ->map(fn ($value) => round((float) $value, 2)), 'total_points' => SalesVolumePoints::totalPointsByUser($userIds, $month, $year) ->map(fn (float $points) => round($points, 2)), }; return $values->filter(fn ($value) => $value > 0)->all(); } /** * Wert absteigend, bei Gleichstand nach Name und zuletzt nach user_id, damit * die Reihenfolge reproduzierbar bleibt. * * @param array $valuesByUserId * @return array */ private function sortedEntries(array $valuesByUserId): array { if ($valuesByUserId === []) { return []; } $users = User::query() ->with('account') ->whereIn('id', array_keys($valuesByUserId)) ->get() ->keyBy('id'); $entries = []; foreach ($valuesByUserId as $userId => $value) { $account = $users->get($userId)?->account; if (! $account) { continue; } $entries[] = [ 'user_id' => (int) $userId, 'first_name' => trim((string) $account->first_name), 'last_name' => trim((string) $account->last_name), 'value' => $value, ]; } usort($entries, 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 $entries; } /** * Plätze wie in der Mail: Gleichstand teilt sich einen Platz, der nächste * Wert bekommt den nächsten Platz (1, 2, 2, 3 → Platz 1, 2, 3). * * @param array $entries * @return array}> */ private static function podium(array $entries, int $places, float|int $minValue): array { $podium = []; foreach ($entries as $entry) { if ($entry['value'] < $minValue) { break; } $current = array_key_last($podium); if ($current === null || $podium[$current]['value'] != $entry['value']) { if (count($podium) >= $places) { break; } $podium[] = ['place' => count($podium) + 1, 'value' => $entry['value'], 'members' => []]; $current = array_key_last($podium); } $podium[$current]['members'][] = [ 'user_id' => $entry['user_id'], 'first_name' => $entry['first_name'], 'last_name' => $entry['last_name'], ]; } return $podium; } /** * Rangliste mit Wettkampf-Rang (1, 2, 2, 4), auf $limit Zeilen gekürzt. * * @param array $entries * @return array */ private static function ranking(array $entries, int $limit): array { $rows = []; foreach (array_slice($entries, 0, $limit) as $index => $entry) { $previous = $rows[$index - 1] ?? null; $rank = $previous !== null && $previous['value'] == $entry['value'] ? $previous['rank'] : $index + 1; $rows[] = ['rank' => $rank] + $entry; } return $rows; } /** * Profilfotos erst beim Lesen ergänzen — nicht mit einfrieren oder cachen, * der Dateiname ändert sich bei jedem Upload ({@see TopResultsService}). * * @param array>> $categories * @return array>> */ private function withProfileImages(array $categories, ?string $nestedKey = null): array { $userIds = collect($categories)->flatten(1) ->flatMap(fn (array $row) => $nestedKey ? $row[$nestedKey] : [$row]) ->pluck('user_id') ->unique() ->values() ->all(); if ($userIds === []) { return $categories; } $imageUrls = User::query() ->with('account') ->whereIn('id', $userIds) ->get() ->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]); $addImage = fn (array $row) => $row + ['profile_image_url' => $imageUrls->get((int) $row['user_id'])]; foreach ($categories as $category => $rows) { foreach ($rows as $index => $row) { if ($nestedKey) { $categories[$category][$index][$nestedKey] = array_map($addImage, $row[$nestedKey]); } else { $categories[$category][$index] = $addImage($row); } } } return $categories; } private static function cacheKey(string $type, int $month, int $year): string { return "recognitions_{$type}_{$year}_{$month}"; } }