mivita/app/Services/Team/RecognitionService.php
Kevin Adametz 7bc551ca1f Anerkennungen: firmenweite Monats-Ranglisten im Salescenter
- Neue Seite Anerkennungen (/user/recognitions, Menue unter News Archiv) fuer
  alle Berater mit aktivem Konto: oben die Anerkennungen des Vormonats in den
  fuenf Kategorien der Monatsmail, Filter zurueck bis Januar 2026; darunter
  Live-Ranglisten (Top 10 je Kategorie) des laufenden Monats, 15 Min. Cache.
- Kategorien: Top-Recruiter (ab 2 Neupartnern), neue Kunden-Abos,
  Kundenabo-Punkte, Kunden-Abos gesamt, Gesamtpunkte (1.000-Punkte-Shop,
  mit Platz 4). Regeln wie TopResultsService/VIP-Statistik, nur firmenweit.
- Firmenkonto (6) und Grosskundin (3) nie aufgefuehrt:
  config mivita.recognition_excluded_user_ids.
- Abgeschlossene Monate sind bis zum 3. des Folgemonats vorlaeufig und werden
  dann eingefroren: neue Tabelle recognition_snapshots, Command
  recognitions:freeze (taeglich 06:15, idempotent, holt offene Monate nach).
- September-Anerkennungen firmenweit geprueft und korrigiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:13:22 +00:00

402 lines
14 KiB
PHP

<?php
namespace App\Services\Team;
use App\Models\RecognitionSnapshot;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\Backoffice\SalesVolumePoints;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Anerkennungen des Monats: firmenweite Ranglisten in fünf Kategorien, wie sie
* bisher monatlich per Mail verschickt wurden (dev/2026-08-12-intensiv-tickets).
*
* Die Kennzahlen folgen denselben Regeln wie die teaminterne Top-3-Karte
* ({@see TopResultsService}) und die VIP-Statistik, nur über alle Berater.
* Ein abgeschlossener Monat wird am {@see self::FREEZE_DAY}. des Folgemonats
* eingefroren ({@see self::freeze()}); bis dahin ist er vorläufig, damit
* Nachbuchungen und Nacht-Repairs noch einfließen.
*/
class RecognitionService
{
public const CATEGORIES = [
'recruiters',
'new_customer_abos',
'customer_abo_points',
'customer_abos_total',
'total_points',
];
public const FIRST_YEAR = 2026;
public const FIRST_MONTH = 1;
public const FREEZE_DAY = 3;
public const LIVE_LIMIT = 10;
private const DEFAULT_PODIUM_PLACES = 3;
/**
* Abweichende Platzzahl je Kategorie — die Mail führte beim
* 1.000-Punkte-Shop immer einen Platz 4 mit.
*/
private const PODIUM_PLACES = [
'total_points' => 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<string, array<int, array{place: int, value: float|int, members: array<int, array<string, mixed>>}>>}
*/
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<string, array<int, array<string, mixed>>>}
*/
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<int, array{month: int, year: int}>
*/
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<string, array<int, array<string, mixed>>>, rankings: array<string, array<int, array<string, mixed>>>}
*/
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<int, float|int> 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<int, float|int> $valuesByUserId
* @return array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}>
*/
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<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
* @return array<int, array{place: int, value: float|int, members: array<int, array{user_id: int, first_name: string, last_name: string}>}>
*/
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<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
* @return array<int, array{rank: int, user_id: int, first_name: string, last_name: string, value: float|int}>
*/
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<string, array<int, array<string, mixed>>> $categories
* @return array<string, array<int, array<string, mixed>>>
*/
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}";
}
}