- 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>
402 lines
14 KiB
PHP
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}";
|
|
}
|
|
}
|