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>
This commit is contained in:
Kevin Adametz 2026-10-09 13:13:22 +00:00
parent 9ad69a3218
commit 7bc551ca1f
22 changed files with 1595 additions and 0 deletions

View file

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Services\Team\RecognitionService;
use Illuminate\Console\Command;
class RecognitionsFreeze extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'recognitions:freeze
{--month= : Nur diesen Monat einfrieren (mit --year)}
{--year= : Nur dieses Jahr einfrieren (mit --month)}
{--force : Bereits eingefrorene Monate neu berechnen}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Friert die Anerkennungen abgeschlossener Monate ein (ab dem 3. des Folgemonats)';
/**
* Ohne Optionen werden alle noch offenen Monate seit dem ersten
* Anerkennungsmonat nachgezogen — der tägliche Lauf holt damit auch einen
* verpassten Stichtag nach. Ein neu gebauter Snapshot rechnet mit dem Stand
* des Ausführungstags; `--force` deshalb nur bewusst einsetzen.
*/
public function handle(RecognitionService $recognitions): int
{
$months = $this->monthsToFreeze();
$force = (bool) $this->option('force');
$stored = 0;
$skipped = 0;
foreach ($months as ['month' => $month, 'year' => $year]) {
if (! RecognitionService::isFreezable($month, $year)) {
$this->line(sprintf('%02d/%d: noch nicht festschreibbar (ab %s)', $month, $year, RecognitionService::freezeDate($month, $year)->format('d.m.Y')));
$skipped++;
continue;
}
if ($recognitions->freeze($month, $year, $force)) {
$this->info(sprintf('%02d/%d: eingefroren', $month, $year));
$stored++;
} else {
$skipped++;
}
}
$this->info("Fertig. Eingefroren: {$stored}, uebersprungen: {$skipped}");
return self::SUCCESS;
}
/**
* @return array<int, array{month: int, year: int}>
*/
private function monthsToFreeze(): array
{
if ($this->option('month') && $this->option('year')) {
return [['month' => max(1, min(12, (int) $this->option('month'))), 'year' => (int) $this->option('year')]];
}
return RecognitionService::closedMonths();
}
}

View file

@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel
// Incentive: Punkteberechnung täglich nach business:store-optimized
$schedule->command('incentive:calculate')->dailyAt('05:00');
// Anerkennungen: abgeschlossene Monate ab dem 3. des Folgemonats einfrieren
// (idempotent; nach Provisionslauf, Repairs und Incentive)
$schedule->command('recognitions:freeze')->dailyAt('06:15');
// Cleanup old log files weekly (keeps logs for 30 days)
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');

View file

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\Recognition\RecognitionFilterRequest;
use App\Services\Team\RecognitionService;
use Illuminate\View\View;
/**
* Anerkennungen im Salescenter: oben die Ranglisten des gewählten
* abgeschlossenen Monats (Standard: Vormonat), darunter der Live-Stand des
* laufenden Monats. Sichtbar für alle Berater mit aktivem Konto.
*/
class RecognitionController extends Controller
{
public function __construct(private RecognitionService $recognitions)
{
$this->middleware('active.account');
}
public function index(RecognitionFilterRequest $request): View
{
$closedMonths = RecognitionService::closedMonths();
$selected = $this->selectedMonth((string) $request->validated('period'), $closedMonths);
return view('user.recognitions.index', [
'periods' => $closedMonths,
'selected' => $selected,
'recognitions' => $selected
? $this->recognitions->recognitions($selected['month'], $selected['year'])
: null,
'live' => $this->recognitions->liveRankings((int) now()->month, (int) now()->year),
]);
}
/**
* Nur abgeschlossene Monate sind wählbar; alles andere fällt auf den Vormonat zurück.
*
* @param array<int, array{month: int, year: int}> $closedMonths
* @return array{month: int, year: int}|null
*/
private function selectedMonth(string $period, array $closedMonths): ?array
{
foreach ($closedMonths as $closedMonth) {
if ($period === sprintf('%d-%02d', $closedMonth['year'], $closedMonth['month'])) {
return $closedMonth;
}
}
return $closedMonths[0] ?? null;
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Recognition;
use Illuminate\Foundation\Http\FormRequest;
class RecognitionFilterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'period' => ['nullable', 'date_format:Y-m'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'period.date_format' => __('recognitions.period_invalid'),
];
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Services\Team\RecognitionService;
use Illuminate\Database\Eloquent\Model;
/**
* Eingefrorene Anerkennungen eines abgeschlossenen Monats
* ({@see RecognitionService::freeze()}).
*/
class RecognitionSnapshot extends Model
{
protected $table = 'recognition_snapshots';
protected $fillable = [
'year',
'month',
'payload',
'frozen_at',
];
protected function casts(): array
{
return [
'year' => 'int',
'month' => 'int',
'payload' => 'array',
'frozen_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,402 @@
<?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}";
}
}

View file

@ -19,4 +19,11 @@ return [
// Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte
// stehen immer dem Berater zu (Kundenentscheid 2026-08-19).
'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6),
// Anerkennungen (Monats-Ranglisten im Salescenter): diese Konten werden nie
// aufgeführt — Firmenkonto (6) und Großkundin Ruth Krummel (3), Kundenentscheid 2026-10-07.
'recognition_excluded_user_ids' => array_values(array_filter(array_map(
'intval',
explode(',', (string) env('MIVITA_RECOGNITION_EXCLUDED_USER_IDS', '3,6'))
))),
];

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recognition_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedSmallInteger('year');
$table->unsignedTinyInteger('month');
$table->json('payload');
$table->timestamp('frozen_at')->nullable();
$table->timestamps();
$table->unique(['year', 'month'], 'recognition_snapshot_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('recognition_snapshots');
}
};

View file

@ -0,0 +1,46 @@
Herzlichen Glückwunsch zu euren großartigen Erfolgen! 🥳💙
🏆 Top-Recruiter – September
🥇 Gabi Ramstötter (2)
🥇 Tanja Longerich (2)
🥇 Claudia Lattner (2)
🌟 Die meisten neuen Kunden-Abos im September
🥇 Platz 1: Verena Zöllinger (7)
🥈 Platz 2: Nicola Maier (6)
🥉 Platz 3: Rosa Maria Assenbaum (4), Carmen Graf (4)
💙 Höchster Umsatz mit Kunden-Abos
🥇 Platz 1: Cornelia Böhm-Sodan – 1.096 Punkte
🥈 Platz 2: Annette Harsch – 1.042 Punkte
🥉 Platz 3: Tanja Longerich – 930 Punkte
⭐ Die meisten Kunden-Abos insgesamt
🥇 Platz 1: Annette Harsch (21)
🥈 Platz 2: Rosa Maria Assenbaum (19)
🥉 Platz 3: Nina Fleschhut (16), Tanja Longerich (16)
🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop
🥇 Platz 1: Claudia Lattner (5.808 P)
🥈 Platz 2: Daniel Biallowons (4.202 P)
🥉 Platz 3: Tanja Longerich (3.279 P)
Platz 4: Claudia Zürn (3.001 P)
---
## Prüfung firmenweit (07.10.2026, Live-Stand) – nicht mitschicken
Nachgerechnet über die gesamte Firma statt nur Claudias Team, mit denselben Regeln wie
Dashboard „Die besten Ergebnisse“ und VIP-Statistik. Grundsätzlich ohne das
Firmenkonto (mivita care gmbh, User 6) und ohne Ruth Krummel (User 3, Großkunde) –
beide werden in den Anerkennungen nie aufgeführt (bestätigt von Kevin, 07.10.2026).
| Kategorie | Ergebnis |
|---|---|
| Top-Recruiter | ✅ stimmt (je 2; dahinter rund 10 Partner mit je 1) |
| Neue Kunden-Abos | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum |
| Umsatz Kunden-Abos | ✅ stimmt (Böhm-Sodan genau 1.096,5 P) |
| Kunden-Abos insgesamt | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum (Klammer fehlte) |
| 1.000-Punkte-Shop | ❌ **falsch**: Daniel Biallowons (4.202,5 P) und Claudia Zürn (3.001,5 P) sind nicht in Claudias Team und fehlten. Jasmin Neuhauser (2.637 P) rutscht auf Platz 5 |

View file

@ -87,6 +87,7 @@ return [
'tools' => 'Tools',
'system_status' => 'Systemstatus',
'news_archive' => 'News Archiv',
'recognitions' => 'Anerkennungen',
'incentive' => 'Incentive',
'incentives' => 'Incentives',
'create' => 'Neu anlegen',

View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'Anerkennungen',
'hero_kicker' => 'Anerkennungen des Monats',
'hero_title' => 'Unsere Erfolge im :month',
'hero_text' => 'Herzlichen Glückwunsch zu euren großartigen Erfolgen! Hinter jedem Platz stehen Begeisterung, Ausdauer und ganz viel Einsatz – wir sind sehr stolz auf euch.',
'period_label' => 'Monat',
'period_invalid' => 'Bitte einen gültigen Monat wählen.',
'show' => 'Anzeigen',
'status_final' => 'Endgültig · festgeschrieben am :date',
'status_preliminary' => 'Vorläufig · wird am :date festgeschrieben',
'place' => 'Platz :place',
'empty' => 'In diesem Monat gibt es hier keine Platzierungen.',
'no_months' => 'Es gibt noch keinen abgeschlossenen Monat.',
'recruiters_min_hint' => 'Platzierung ab 2 Neupartnern im Monat.',
'live_title' => 'Live: So entwickelt sich der :month',
'live_text' => 'Die aktuellen Top :limit je Kategorie – alles ist noch offen!',
'live_updated' => 'Stand: :time Uhr · wird alle 15 Minuten aktualisiert',
'live_rank' => 'Platz',
'live_name' => 'Name',
'live_value' => 'Wert',
'live_empty' => 'Noch keine Einträge in diesem Monat.',
'self' => 'ich',
'categories' => [
'recruiters' => [
'title' => 'Top-Recruiter',
'description' => 'Die meisten neuen Partner im Monat (direkt gesponsert, Starterpaket bezahlt).',
'unit' => 'Neupartner',
],
'new_customer_abos' => [
'title' => 'Die meisten neuen Kunden-Abos',
'description' => 'Neu abgeschlossene Kunden-Abos im Monat.',
'unit' => 'neue Abos',
],
'customer_abo_points' => [
'title' => 'Höchster Umsatz mit Kunden-Abos',
'description' => 'Punkte aus Kunden-Abo-Bestellungen im Monat.',
'unit' => 'Punkte',
],
'customer_abos_total' => [
'title' => 'Die meisten Kunden-Abos insgesamt',
'description' => 'Aktive Kunden-Abos zum Monatsende (im laufenden Monat: heute).',
'unit' => 'Abos',
],
'total_points' => [
'title' => 'Kundenumsatz & Recruiting – 1.000-Punkte-Shop',
'description' => 'Höchste Punktzahl aus Kundenumsatz, Eigenumsatz und Recruiting.',
'unit' => 'Punkte',
],
],
];

View file

@ -87,6 +87,7 @@ return [
'tools' => 'Tools',
'system_status' => 'System status',
'news_archive' => 'News Archive',
'recognitions' => 'Recognitions',
'incentive' => 'Incentive',
'incentives' => 'Incentives',
'create' => 'Create new',

View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'Recognitions',
'hero_kicker' => 'Recognitions of the month',
'hero_title' => 'Our successes in :month',
'hero_text' => 'Congratulations on your great achievements! Every place stands for enthusiasm, perseverance and a lot of commitment – we are very proud of you.',
'period_label' => 'Month',
'period_invalid' => 'Please choose a valid month.',
'show' => 'Show',
'status_final' => 'Final · locked on :date',
'status_preliminary' => 'Preliminary · will be locked on :date',
'place' => 'Place :place',
'empty' => 'No placements this month.',
'no_months' => 'There is no completed month yet.',
'recruiters_min_hint' => 'Placement from 2 new partners per month.',
'live_title' => 'Live: how :month is shaping up',
'live_text' => 'The current top :limit per category – everything is still open!',
'live_updated' => 'As of :time · updated every 15 minutes',
'live_rank' => 'Place',
'live_name' => 'Name',
'live_value' => 'Value',
'live_empty' => 'No entries this month yet.',
'self' => 'me',
'categories' => [
'recruiters' => [
'title' => 'Top recruiters',
'description' => 'Most new partners in the month (directly sponsored, starter package paid).',
'unit' => 'new partners',
],
'new_customer_abos' => [
'title' => 'Most new customer subscriptions',
'description' => 'Newly started customer subscriptions in the month.',
'unit' => 'new subscriptions',
],
'customer_abo_points' => [
'title' => 'Highest revenue from customer subscriptions',
'description' => 'Points from customer subscription orders in the month.',
'unit' => 'points',
],
'customer_abos_total' => [
'title' => 'Most customer subscriptions overall',
'description' => 'Active customer subscriptions at month end (current month: today).',
'unit' => 'subscriptions',
],
'total_points' => [
'title' => 'Customer revenue & recruiting – 1,000 points shop',
'description' => 'Highest points from customer revenue, own revenue and recruiting.',
'unit' => 'points',
],
],
];

View file

@ -84,6 +84,7 @@ return [
'tools' => 'Herramientas',
'system_status' => 'Estado del sistema',
'news_archive' => 'Archivo de Noticias',
'recognitions' => 'Reconocimientos',
'incentive' => 'Incentivo',
'incentives' => 'Incentivos',
'create' => 'Crear nuevo',

View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'Reconocimientos',
'hero_kicker' => 'Reconocimientos del mes',
'hero_title' => 'Nuestros éxitos en :month',
'hero_text' => '¡Enhorabuena por vuestros grandes logros! Detrás de cada puesto hay entusiasmo, constancia y mucho esfuerzo: estamos muy orgullosos de vosotros.',
'period_label' => 'Mes',
'period_invalid' => 'Elige un mes válido.',
'show' => 'Mostrar',
'status_final' => 'Definitivo · cerrado el :date',
'status_preliminary' => 'Provisional · se cerrará el :date',
'place' => 'Puesto :place',
'empty' => 'No hay clasificaciones este mes.',
'no_months' => 'Todavía no hay ningún mes cerrado.',
'recruiters_min_hint' => 'Clasificación a partir de 2 nuevos socios en el mes.',
'live_title' => 'En directo: así evoluciona :month',
'live_text' => 'El top :limit actual por categoría: ¡todo sigue abierto!',
'live_updated' => 'Estado: :time · se actualiza cada 15 minutos',
'live_rank' => 'Puesto',
'live_name' => 'Nombre',
'live_value' => 'Valor',
'live_empty' => 'Todavía no hay entradas este mes.',
'self' => 'yo',
'categories' => [
'recruiters' => [
'title' => 'Mejores reclutadores',
'description' => 'Más socios nuevos en el mes (patrocinio directo, paquete de inicio pagado).',
'unit' => 'socios nuevos',
],
'new_customer_abos' => [
'title' => 'Más suscripciones nuevas de clientes',
'description' => 'Suscripciones de clientes iniciadas en el mes.',
'unit' => 'suscripciones nuevas',
],
'customer_abo_points' => [
'title' => 'Mayor facturación con suscripciones de clientes',
'description' => 'Puntos de pedidos de suscripciones de clientes en el mes.',
'unit' => 'puntos',
],
'customer_abos_total' => [
'title' => 'Más suscripciones de clientes en total',
'description' => 'Suscripciones de clientes activas a final de mes (mes en curso: hoy).',
'unit' => 'suscripciones',
],
'total_points' => [
'title' => 'Facturación de clientes y reclutamiento – tienda de 1.000 puntos',
'description' => 'Mayor puntuación por facturación de clientes, facturación propia y reclutamiento.',
'unit' => 'puntos',
],
],
];

View file

@ -84,6 +84,7 @@ return [
'tools' => 'Outils',
'system_status' => 'État du système',
'news_archive' => 'Archive des actualités',
'recognitions' => 'Distinctions',
'incentive' => 'Incentive',
'incentives' => 'Incentives',
'create' => 'Créer',

View file

@ -0,0 +1,52 @@
<?php
return [
'title' => 'Distinctions',
'hero_kicker' => 'Distinctions du mois',
'hero_title' => 'Nos succès en :month',
'hero_text' => 'Félicitations pour vos magnifiques résultats ! Derrière chaque place, il y a de l’enthousiasme, de la persévérance et beaucoup d’engagement – nous sommes très fiers de vous.',
'period_label' => 'Mois',
'period_invalid' => 'Veuillez choisir un mois valide.',
'show' => 'Afficher',
'status_final' => 'Définitif · arrêté le :date',
'status_preliminary' => 'Provisoire · sera arrêté le :date',
'place' => 'Place :place',
'empty' => 'Aucun classement ce mois-ci.',
'no_months' => 'Il n’y a encore aucun mois clôturé.',
'recruiters_min_hint' => 'Classement à partir de 2 nouveaux partenaires dans le mois.',
'live_title' => 'En direct : l’évolution de :month',
'live_text' => 'Le top :limit actuel par catégorie – tout reste possible !',
'live_updated' => 'État : :time · mis à jour toutes les 15 minutes',
'live_rank' => 'Place',
'live_name' => 'Nom',
'live_value' => 'Valeur',
'live_empty' => 'Pas encore d’entrées ce mois-ci.',
'self' => 'moi',
'categories' => [
'recruiters' => [
'title' => 'Meilleurs recruteurs',
'description' => 'Le plus de nouveaux partenaires dans le mois (parrainage direct, pack de démarrage payé).',
'unit' => 'nouveaux partenaires',
],
'new_customer_abos' => [
'title' => 'Le plus de nouveaux abonnements clients',
'description' => 'Abonnements clients démarrés dans le mois.',
'unit' => 'nouveaux abonnements',
],
'customer_abo_points' => [
'title' => 'Plus gros chiffre d’affaires avec les abonnements clients',
'description' => 'Points issus des commandes d’abonnements clients dans le mois.',
'unit' => 'points',
],
'customer_abos_total' => [
'title' => 'Le plus d’abonnements clients au total',
'description' => 'Abonnements clients actifs en fin de mois (mois en cours : aujourd’hui).',
'unit' => 'abonnements',
],
'total_points' => [
'title' => 'CA clients & recrutement – boutique 1 000 points',
'description' => 'Le plus de points grâce au CA clients, au CA personnel et au recrutement.',
'unit' => 'points',
],
],
];

View file

@ -55,6 +55,12 @@
<div>{{ __('navigation.news_archive') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('user/recognitions') ? ' active' : '' }}">
<a href="{{ route('user_recognitions') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-trophy"></i>
<div>{{ __('navigation.recognitions') }}</div>
</a>
</li>
@if (Auth::user()->isVIP())
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">

View file

@ -0,0 +1,324 @@
@extends('layouts.layout-2')
@section('styles')
<style>
.recognition-hero {
position: relative;
overflow: hidden;
border-radius: .75rem;
background: linear-gradient(135deg, #6b7758 0%, #4f5940 100%);
color: #fff;
}
.recognition-hero .hero-trophy {
position: absolute;
right: -1rem;
bottom: -2.5rem;
font-size: 13rem;
line-height: 1;
color: rgba(255, 255, 255, .08);
pointer-events: none;
}
.recognition-hero .hero-kicker {
letter-spacing: .08em;
color: #f4d77a;
}
.recognition-hero .badge-status {
background: rgba(255, 255, 255, .18);
color: #fff;
font-weight: 500;
}
.recognition-card .card-header {
border-bottom: 0;
padding-bottom: .25rem;
}
.recognition-card .category-icon {
font-size: 1.6rem;
line-height: 1;
}
.recognition-place {
display: flex;
align-items: center;
border-radius: .5rem;
padding: .6rem .75rem;
margin-bottom: .5rem;
background: #f7f7f5;
}
.recognition-place.place-1 {
background: #fff7dc;
box-shadow: inset 3px 0 0 #e0b537;
}
.recognition-place.place-2 {
background: #f1f3f5;
box-shadow: inset 3px 0 0 #a9b0b8;
}
.recognition-place.place-3 {
background: #fbefe6;
box-shadow: inset 3px 0 0 #c98a5a;
}
.recognition-place .place-medal {
flex: 0 0 2.25rem;
font-size: 1.6rem;
line-height: 1;
text-align: center;
}
.recognition-place .place-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
background: #dfe2d8;
color: #4f5940;
font-weight: 700;
font-size: .85rem;
}
.recognition-place .place-members {
flex: 1 1 auto;
min-width: 0;
}
.recognition-place .place-member {
display: flex;
align-items: center;
}
.recognition-place .place-member + .place-member {
margin-top: .35rem;
}
.recognition-place .place-value {
flex: 0 0 auto;
text-align: right;
padding-left: .75rem;
}
.recognition-live .live-dot {
display: inline-block;
width: .6rem;
height: .6rem;
border-radius: 50%;
background: #d9534f;
margin-right: .4rem;
animation: recognition-pulse 1.6s infinite;
}
@keyframes recognition-pulse {
0% { box-shadow: 0 0 0 0 rgba(217, 83, 79, .6); }
70% { box-shadow: 0 0 0 .5rem rgba(217, 83, 79, 0); }
100% { box-shadow: 0 0 0 0 rgba(217, 83, 79, 0); }
}
.recognition-live .nav-pills .nav-link {
font-size: .85rem;
}
.recognition-live .nav-pills .nav-link.active,
.recognition-live .nav-pills .nav-link.active:hover,
.recognition-live .nav-pills .nav-link.active:focus {
background-color: #d7d700;
color: #565600;
}
.recognition-live .table td {
vertical-align: middle;
}
</style>
@endsection
@section('content')
@php
$viewerId = (int) auth()->id();
$medals = [1 => '🥇', 2 => '🥈', 3 => '🥉'];
$categoryIcons = [
'recruiters' => '🏆',
'new_customer_abos' => '🌟',
'customer_abo_points' => '💙',
'customer_abos_total' => '⭐',
'total_points' => '🚀',
];
$formatValue = fn ($value) => number_format((float) $value, floor((float) $value) == (float) $value ? 0 : 1, ',', '.');
$periodOptions = collect($periods)->mapWithKeys(fn (array $period) => [
sprintf('%d-%02d', $period['year'], $period['month']) => HTMLHelper::getMonth($period['month']).' '.$period['year'],
])->all();
$selectedPeriod = $selected ? sprintf('%d-%02d', $selected['year'], $selected['month']) : null;
@endphp
<h4 class="font-weight-bold py-2 mb-2 d-flex flex-wrap justify-content-between align-items-center w-100">
<div class="mb-2 mb-sm-0">
<i class="ion ion-md-trophy text-warning mr-1"></i>{{ __('recognitions.title') }}
</div>
@if ($periodOptions !== [])
<form method="GET" action="{{ route('user_recognitions') }}" class="form-inline">
<label for="period" class="small text-muted font-weight-normal mr-2">{{ __('recognitions.period_label') }}</label>
{{ html()->select('period', $periodOptions, $selectedPeriod)->id('period')->class('custom-select custom-select-sm mr-2')->attribute('onchange', 'this.form.submit()') }}
<noscript><button type="submit" class="btn btn-sm btn-primary">{{ __('recognitions.show') }}</button></noscript>
</form>
@endif
</h4>
@if ($recognitions === null)
<div class="alert alert-info">{{ __('recognitions.no_months') }}</div>
@else
<div class="recognition-hero p-4 p-md-5 mb-4">
<i class="ion ion-md-trophy hero-trophy"></i>
<div class="position-relative">
<div class="hero-kicker text-uppercase small font-weight-bold mb-2">{{ __('recognitions.hero_kicker') }}</div>
<h2 class="font-weight-bold text-white mb-2">
{{ __('recognitions.hero_title', ['month' => HTMLHelper::getMonth($recognitions['month']).' '.$recognitions['year']]) }}
🥳💙
</h2>
<p class="mb-3" style="max-width: 46rem; opacity: .9;">{{ __('recognitions.hero_text') }}</p>
@if ($recognitions['is_final'])
<span class="badge badge-status px-2 py-1">
<i class="ion ion-md-lock mr-1"></i>{{ __('recognitions.status_final', ['date' => $recognitions['frozen_at']?->format('d.m.Y')]) }}
</span>
@else
<span class="badge badge-status px-2 py-1">
<i class="ion ion-md-time mr-1"></i>{{ __('recognitions.status_preliminary', ['date' => $recognitions['freeze_date']->format('d.m.Y')]) }}
</span>
@endif
</div>
</div>
<div class="row">
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
@php $podium = $recognitions['categories'][$category] ?? []; @endphp
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-4">
<div class="card recognition-card w-100">
<div class="card-header bg-transparent d-flex align-items-start">
<span class="category-icon mr-2">{{ $categoryIcons[$category] }}</span>
<div>
<h6 class="font-weight-bold mb-1">{{ __('recognitions.categories.'.$category.'.title') }}</h6>
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.description') }}</div>
</div>
</div>
<div class="card-body pt-2">
@forelse ($podium as $place)
<div class="recognition-place place-{{ $place['place'] }}">
<div class="place-medal">
@if (isset($medals[$place['place']]))
<span title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $medals[$place['place']] }}</span>
@else
<span class="place-number" title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $place['place'] }}</span>
@endif
</div>
<div class="place-members ml-2">
@foreach ($place['members'] as $member)
<div class="place-member">
@include('components._avatar', [
'imageUrl' => $member['profile_image_url'] ?? null,
'firstName' => $member['first_name'],
'lastName' => $member['last_name'],
'size' => 36,
])
<span class="ml-2 font-weight-semibold text-truncate">
{{ $member['first_name'] }} {{ $member['last_name'] }}
@if ((int) $member['user_id'] === $viewerId)
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
@endif
</span>
</div>
@endforeach
</div>
<div class="place-value">
<div class="font-weight-bold" style="font-size: 1.1rem;">{{ $formatValue($place['value']) }}</div>
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.unit') }}</div>
</div>
</div>
@empty
<div class="text-muted small">{{ __('recognitions.empty') }}</div>
@endforelse
@if ($category === 'recruiters')
<div class="text-muted small mt-2">{{ __('recognitions.recruiters_min_hint') }}</div>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
<div class="card recognition-live mb-4">
<div class="card-header">
<h5 class="mb-1 d-flex align-items-center">
<span class="live-dot"></span>
{{ __('recognitions.live_title', ['month' => HTMLHelper::getMonth($live['month']).' '.$live['year']]) }}
</h5>
<div class="text-muted small">
{{ __('recognitions.live_text', ['limit' => \App\Services\Team\RecognitionService::LIVE_LIMIT]) }}
· {{ __('recognitions.live_updated', ['time' => $live['calculated_at']->format('d.m.Y H:i')]) }}
</div>
</div>
<div class="card-body">
<ul class="nav nav-pills flex-wrap mb-3" role="tablist">
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
<li class="nav-item mb-1">
<a class="nav-link {{ $loop->first ? 'active' : '' }}" data-toggle="tab" href="#live-{{ $category }}" role="tab">
{{ $categoryIcons[$category] }} {{ __('recognitions.categories.'.$category.'.title') }}
</a>
</li>
@endforeach
</ul>
<div class="tab-content">
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
<div class="tab-pane fade {{ $loop->first ? 'show active' : '' }}" id="live-{{ $category }}" role="tabpanel">
@if (empty($live['categories'][$category]))
<div class="text-muted small">{{ __('recognitions.live_empty') }}</div>
@else
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 5rem;">{{ __('recognitions.live_rank') }}</th>
<th>{{ __('recognitions.live_name') }}</th>
<th class="text-right">{{ __('recognitions.categories.'.$category.'.unit') }}</th>
</tr>
</thead>
<tbody>
@foreach ($live['categories'][$category] as $row)
<tr class="{{ (int) $row['user_id'] === $viewerId ? 'table-info' : '' }}">
<td class="font-weight-bold">
{{ $medals[$row['rank']] ?? $row['rank'].'.' }}
</td>
<td>
<div class="d-flex align-items-center">
@include('components._avatar', [
'imageUrl' => $row['profile_image_url'] ?? null,
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'size' => 32,
])
<span class="ml-2">
{{ $row['first_name'] }} {{ $row['last_name'] }}
@if ((int) $row['user_id'] === $viewerId)
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
@endif
</span>
</div>
</td>
<td class="text-right font-weight-bold">{{ $formatValue($row['value']) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endforeach
</div>
</div>
</div>
@endsection

View file

@ -134,6 +134,9 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export');
Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export');
// user recognitions (Anerkennungen des Monats, firmenweit)
Route::get('/user/recognitions', 'User\RecognitionController@index')->name('user_recognitions');
// user team
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');

View file

@ -0,0 +1,115 @@
<?php
use App\Models\RecognitionSnapshot;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
$this->refreshApplication();
$this->artisan('migrate');
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
Cache::flush();
config(['mivita.recognition_excluded_user_ids' => []]);
});
afterEach(function () {
Carbon::setTestNow();
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
if ($this->previousHttpHost === null) {
unset($_SERVER['HTTP_HOST']);
} else {
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
}
});
function recPageUser(string $firstName, string $lastName, array $attributes = []): User
{
$account = UserAccount::forceCreate(['first_name' => $firstName, 'last_name' => $lastName]);
return User::forceCreate(array_merge([
'email' => 'recp-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'wizard' => 100,
'm_level' => 1,
'payment_account' => now()->addYear(),
'account_id' => $account->id,
], $attributes));
}
function recPageVolume(int $userId, int $points, int $month): void
{
UserSalesVolume::forceCreate([
'user_id' => $userId,
'month' => $month,
'year' => 2026,
'points' => $points,
'status' => 1,
]);
}
it('zeigt aktiven Beratern die Anerkennungen des Vormonats und die Live-Tabelle', function () {
$viewer = recPageUser('Vera', 'Viewer');
$september = recPageUser('Sina', 'September');
$october = recPageUser('Otto', 'Oktober');
recPageVolume($september->id, 5808, 9);
recPageVolume($october->id, 1234, 10);
recPageVolume($viewer->id, 800, 10);
$this->actingAs($viewer)->get(route('user_recognitions'))
->assertOk()
->assertSee('Unsere Erfolge im September 2026')
->assertSee('Vorläufig · wird am 03.10.2026 festgeschrieben', false)
->assertSee('Sina September')
->assertSee('5.808')
->assertSee('Live: So entwickelt sich der Oktober 2026')
->assertSee('Otto Oktober')
->assertSee('1.234')
->assertSee('<tr class="table-info">', false)
->assertSee('href="'.route('user_recognitions').'"', false);
});
it('zeigt eingefrorene Monate als endgültig und wechselt per Filter den Monat', function () {
$viewer = recPageUser('Vera', 'Viewer');
$august = recPageUser('Augusta', 'August');
recPageVolume($august->id, 4321, 8);
$this->artisan('recognitions:freeze', ['--month' => 8, '--year' => 2026])->assertSuccessful();
expect(RecognitionSnapshot::query()->where('month', 8)->exists())->toBeTrue();
$this->actingAs($viewer)->get(route('user_recognitions', ['period' => '2026-08']))
->assertOk()
->assertSee('Unsere Erfolge im August 2026')
->assertSee('Endgültig · festgeschrieben am 09.10.2026', false)
->assertSee('Augusta August')
->assertSee('<option value="2026-08" selected="selected">', false);
});
it('faellt bei einem nicht abgeschlossenen Monat auf den Vormonat zurueck', function () {
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => '2026-10']))
->assertOk()
->assertSee('Unsere Erfolge im September 2026');
});
it('lehnt ein ungueltiges Monatsformat ab', function () {
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => 'kaputt']))
->assertRedirect()
->assertSessionHasErrors('period');
});
it('sperrt die Seite fuer Berater ohne aktives Konto', function () {
$this->actingAs(recPageUser('Ina', 'Inaktiv', ['payment_account' => now()->subDay()]))
->get(route('user_recognitions'))
->assertRedirect('/home');
});

View file

@ -0,0 +1,253 @@
<?php
use App\Models\RecognitionSnapshot;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\Team\RecognitionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
Cache::flush();
config(['mivita.recognition_excluded_user_ids' => []]);
});
afterEach(function () {
Carbon::setTestNow();
});
function recMakeUser(string $firstName, string $lastName = 'Partner', ?int $sponsorId = null, ?string $partnerPaidAt = null): User
{
$account = UserAccount::forceCreate([
'first_name' => $firstName,
'last_name' => $lastName,
]);
return User::forceCreate([
'email' => 'rec-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
'active_date' => $partnerPaidAt,
'partner_paid_at' => $partnerPaidAt,
'account_id' => $account->id,
]);
}
function recAddVolume(int $userId, float $points, int $status, int $month = 9, ?int $shoppingOrderId = null): void
{
UserSalesVolume::forceCreate([
'user_id' => $userId,
'month' => $month,
'year' => 2026,
'points' => $points,
'status' => $status,
'shopping_order_id' => $shoppingOrderId,
]);
}
function recAddCustomerAboPoints(int $userId, float $points, int $month = 9): void
{
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => 0,
'country_id' => 0,
'user_shop_id' => 0,
'member_id' => $userId,
'is_abo' => true,
]);
recAddVolume($userId, $points, 2, $month, $order->id);
}
function recMakeCustomerAbo(int $consultantId, string $startDate): UserAbo
{
return UserAbo::create([
'user_id' => 0,
'member_id' => $consultantId,
'shopping_user_id' => 0,
'email' => 'rec-abo-'.uniqid('', true).'@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'next_date' => '2026-11-05',
]);
}
/**
* @param array<int, array<string, mixed>> $podium
* @return array<int, array{place: int, value: float|int, names: array<int, string>}>
*/
function recPlaces(array $podium): array
{
return array_map(fn (array $place) => [
'place' => $place['place'],
'value' => $place['value'],
'names' => array_map(fn (array $member) => $member['first_name'], $place['members']),
], $podium);
}
it('rankt alle fuenf Kategorien firmenweit, unabhaengig vom Team', function (): void {
$anna = recMakeUser('Anna');
$ben = recMakeUser('Ben');
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-20');
recMakeUser('Alt', 'Partner', $anna->id, '2026-08-20');
recMakeCustomerAbo($ben->id, '2026-09-05');
recMakeCustomerAbo($ben->id, '2026-09-15');
recMakeCustomerAbo($ben->id, '2026-06-01');
recMakeCustomerAbo($anna->id, '2026-09-02');
recAddCustomerAboPoints($ben->id, 120.5);
recAddCustomerAboPoints($anna->id, 80);
recAddVolume($anna->id, 900, 1);
recAddVolume($ben->id, 50, 1, 8);
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
expect(recPlaces($categories['recruiters']))->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]])
->and(recPlaces($categories['new_customer_abos']))->toBe([
['place' => 1, 'value' => 2, 'names' => ['Ben']],
['place' => 2, 'value' => 1, 'names' => ['Anna']],
])
->and(recPlaces($categories['customer_abos_total']))->toBe([
['place' => 1, 'value' => 3, 'names' => ['Ben']],
['place' => 2, 'value' => 1, 'names' => ['Anna']],
])
->and(recPlaces($categories['customer_abo_points']))->toBe([
['place' => 1, 'value' => 120.5, 'names' => ['Ben']],
['place' => 2, 'value' => 80.0, 'names' => ['Anna']],
])
->and(recPlaces($categories['total_points']))->toBe([
['place' => 1, 'value' => 980.0, 'names' => ['Anna']],
['place' => 2, 'value' => 120.5, 'names' => ['Ben']],
]);
});
it('fuehrt Firmenkonto und Grosskunden nie auf', function (): void {
$company = recMakeUser('Firma', 'GmbH');
$bigCustomer = recMakeUser('Ruth', 'Gross');
$anna = recMakeUser('Anna');
config(['mivita.recognition_excluded_user_ids' => [$company->id, $bigCustomer->id]]);
recAddVolume($company->id, 5000, 2);
recAddVolume($bigCustomer->id, 7000, 1);
recAddVolume($anna->id, 300, 1);
$service = app(RecognitionService::class);
expect(recPlaces($service->recognitions(9, 2026)['categories']['total_points']))
->toBe([['place' => 1, 'value' => 300.0, 'names' => ['Anna']]])
->and(array_column($service->liveRankings(10, 2026)['categories']['total_points'], 'user_id'))
->not->toContain($company->id, $bigCustomer->id);
});
it('teilt Gleichstaende einen Platz und vergibt die Plaetze lueckenlos', function (): void {
foreach (['Carla' => 500, 'Anna' => 400, 'Ben' => 400, 'Dora' => 300, 'Emil' => 200, 'Fritz' => 100] as $name => $points) {
recAddVolume(recMakeUser($name)->id, $points, 1);
}
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
// 1.000-Punkte-Shop mit Platz 4 (wie in der Mail), danach ist Schluss.
expect(recPlaces($categories['total_points']))->toBe([
['place' => 1, 'value' => 500.0, 'names' => ['Carla']],
['place' => 2, 'value' => 400.0, 'names' => ['Anna', 'Ben']],
['place' => 3, 'value' => 300.0, 'names' => ['Dora']],
['place' => 4, 'value' => 200.0, 'names' => ['Emil']],
]);
});
it('platziert Recruiter erst ab zwei Neupartnern', function (): void {
$anna = recMakeUser('Anna');
$ben = recMakeUser('Ben');
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-11');
recMakeUser('Neu3', 'Partner', $ben->id, '2026-09-12');
$service = app(RecognitionService::class);
expect(recPlaces($service->recognitions(9, 2026)['categories']['recruiters']))
->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]]);
});
it('zeigt Live-Ranglisten mit Wettkampf-Rang und hoechstens zehn Zeilen', function (): void {
foreach (range(1, 12) as $index) {
recAddVolume(recMakeUser('User'.str_pad((string) $index, 2, '0', STR_PAD_LEFT))->id, $index === 2 ? 1200 : 100 * $index, 1, 10);
}
$rows = app(RecognitionService::class)->liveRankings(10, 2026)['categories']['total_points'];
expect($rows)->toHaveCount(10)
->and(array_column(array_slice($rows, 0, 3), 'rank'))->toBe([1, 1, 3])
->and(array_column(array_slice($rows, 0, 3), 'first_name'))->toBe(['User02', 'User12', 'User11'])
->and($rows[0])->toHaveKey('profile_image_url');
});
it('liefert vor dem Stichtag den vorlaeufigen Stand und danach den eingefrorenen', function (): void {
$anna = recMakeUser('Anna');
recAddVolume($anna->id, 300, 1);
Carbon::setTestNow(Carbon::parse('2026-10-02 12:00:00'));
$service = app(RecognitionService::class);
$preliminary = $service->recognitions(9, 2026);
expect($preliminary['is_final'])->toBeFalse()
->and($preliminary['freeze_date']->toDateString())->toBe('2026-10-03');
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026])->assertSuccessful();
// Spaetere Buchungen veraendern den festgeschriebenen Monat nicht mehr.
recAddVolume(recMakeUser('Spaet')->id, 9999, 1);
Cache::flush();
$final = $service->recognitions(9, 2026);
expect($final['is_final'])->toBeTrue()
->and($final['frozen_at']->toDateString())->toBe('2026-10-03')
->and(recPlaces($final['categories']['total_points']))->toEqual([['place' => 1, 'value' => 300, 'names' => ['Anna']]]);
});
it('friert per Command nur festschreibbare Monate ein und ueberschreibt nichts ohne --force', function (): void {
$anna = recMakeUser('Anna');
recAddVolume($anna->id, 300, 1);
Carbon::setTestNow(Carbon::parse('2026-10-02 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
expect(RecognitionSnapshot::query()->where('year', 2026)->where('month', 9)->exists())->toBeFalse()
->and(RecognitionSnapshot::query()->where('year', 2026)->where('month', 8)->exists())->toBeTrue()
->and(RecognitionSnapshot::query()->count())->toBe(8);
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
$frozenAt = RecognitionSnapshot::query()->where('month', 9)->value('frozen_at');
Carbon::setTestNow(Carbon::parse('2026-10-04 06:15:00'));
$this->artisan('recognitions:freeze')->assertSuccessful();
expect(RecognitionSnapshot::query()->count())->toBe(9)
->and(RecognitionSnapshot::query()->where('month', 9)->value('frozen_at'))->toEqual($frozenAt);
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026, '--force' => true])->assertSuccessful();
expect(RecognitionSnapshot::query()->where('month', 9)->first()->frozen_at->toDateString())->toBe('2026-10-04');
});